feat: enhance evobgp with new command-line tools for bundle management, including pull, verify, and apply functionalities. Update go.mod to include necessary dependencies and complete todos in architecture plan for improved observability and deployment practices.
This commit is contained in:
@@ -0,0 +1,51 @@
|
||||
package bundle
|
||||
|
||||
import (
|
||||
"crypto/ed25519"
|
||||
"crypto/rand"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestBuildVerifyRoundtrip(t *testing.T) {
|
||||
_, priv, err := ed25519.GenerateKey(rand.Reader)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
frag := map[string]string{
|
||||
"bird.conf": "router id 192.0.2.1;\n\nprotocol device {\n}\n\nprotocol direct {\n ipv4;\n ipv6;\n}\n",
|
||||
}
|
||||
raw, err := BuildGzippedTar("rev-1", "sp-1", frag, priv)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
pub := priv.Public().(ed25519.PublicKey)
|
||||
v, err := VerifyGzippedTar(raw, pub)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if v.Manifest.RevisionID != "rev-1" {
|
||||
t.Fatalf("revision %q", v.Manifest.RevisionID)
|
||||
}
|
||||
if len(v.Files) != 1 {
|
||||
t.Fatalf("files %d", len(v.Files))
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerifyWrongKey(t *testing.T) {
|
||||
_, privA, err := ed25519.GenerateKey(rand.Reader)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, privB, err := ed25519.GenerateKey(rand.Reader)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
raw, err := BuildGzippedTar("rev-x", "sp-x", map[string]string{"a.conf": "x"}, privA)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
pubB := privB.Public().(ed25519.PublicKey)
|
||||
if _, err := VerifyGzippedTar(raw, pubB); err == nil {
|
||||
t.Fatal("expected signature error with wrong public key")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
package bundle
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
pathpkg "path"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// WriteExtractedFiles writes payload files from a verified bundle under root (directories created as needed).
|
||||
func WriteExtractedFiles(root string, v *VerifiedContents) error {
|
||||
root = filepath.Clean(root)
|
||||
for _, fe := range v.Manifest.Files {
|
||||
data, ok := v.Files[fe.Path]
|
||||
if !ok {
|
||||
return fmt.Errorf("bundle: missing file %q", fe.Path)
|
||||
}
|
||||
rel := pathpkg.Clean(strings.TrimPrefix(fe.Path, "/"))
|
||||
if rel == "." || strings.HasPrefix(rel, "..") {
|
||||
return fmt.Errorf("bundle: unsafe path %q", fe.Path)
|
||||
}
|
||||
dest := filepath.Join(root, filepath.FromSlash(rel))
|
||||
relToRoot, err := filepath.Rel(root, dest)
|
||||
if err != nil || strings.HasPrefix(relToRoot, "..") {
|
||||
return fmt.Errorf("bundle: path escapes root: %q", fe.Path)
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(dest), 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.WriteFile(dest, data, 0o644); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// FindMainBirdConf returns the first path ending with bird.conf from extracted manifest order.
|
||||
func (v *VerifiedContents) FindMainBirdConf() string {
|
||||
for _, fe := range v.Manifest.Files {
|
||||
if strings.HasSuffix(fe.Path, "bird.conf") {
|
||||
return fe.Path
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
package bundle
|
||||
|
||||
import (
|
||||
"archive/tar"
|
||||
"bytes"
|
||||
"compress/gzip"
|
||||
"crypto/ed25519"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"path"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Manifest describes bundle contents for evobgp-node verification.
|
||||
type Manifest struct {
|
||||
RevisionID string `json:"revision_id"`
|
||||
SpeakerID string `json:"speaker_id,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
Files []FileEntry `json:"files"`
|
||||
Algorithm string `json:"signature_algorithm"`
|
||||
PublicKeyB64 string `json:"public_key_base64"`
|
||||
}
|
||||
|
||||
// FileEntry is one file inside the bundle archive.
|
||||
type FileEntry struct {
|
||||
Path string `json:"path"`
|
||||
SHA256 string `json:"sha256"`
|
||||
}
|
||||
|
||||
const sigFileName = "bundle.sig"
|
||||
const manifestName = "manifest.json"
|
||||
|
||||
// BuildGzippedTar builds a .tar.gz with manifest.json, bundle.sig (Ed25519 over manifest JSON), and payload files.
|
||||
func BuildGzippedTar(revisionID, speakerID string, fragments map[string]string, priv ed25519.PrivateKey) ([]byte, error) {
|
||||
if len(priv) != ed25519.PrivateKeySize {
|
||||
return nil, fmt.Errorf("bundle: invalid ed25519 private key size")
|
||||
}
|
||||
pub := priv.Public().(ed25519.PublicKey)
|
||||
|
||||
norm := make(map[string]string, len(fragments))
|
||||
for p, content := range fragments {
|
||||
p = path.Clean(strings.TrimPrefix(p, "/"))
|
||||
if p == "." || strings.HasPrefix(p, "..") {
|
||||
return nil, fmt.Errorf("bundle: invalid path %q", p)
|
||||
}
|
||||
norm[p] = content
|
||||
}
|
||||
|
||||
var files []FileEntry
|
||||
var payload [][]byte
|
||||
keys := sortedStringKeys(norm)
|
||||
for _, p := range keys {
|
||||
data := []byte(norm[p])
|
||||
h := sha256.Sum256(data)
|
||||
files = append(files, FileEntry{Path: p, SHA256: fmt.Sprintf("%x", h[:])})
|
||||
payload = append(payload, data)
|
||||
}
|
||||
|
||||
m := Manifest{
|
||||
RevisionID: revisionID,
|
||||
SpeakerID: speakerID,
|
||||
CreatedAt: time.Now().UTC(),
|
||||
Files: files,
|
||||
Algorithm: "ed25519",
|
||||
PublicKeyB64: base64.StdEncoding.EncodeToString(pub),
|
||||
}
|
||||
manifestJSON, err := json.MarshalIndent(m, "", " ")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
sig := ed25519.Sign(priv, manifestJSON)
|
||||
|
||||
var buf bytes.Buffer
|
||||
gw := gzip.NewWriter(&buf)
|
||||
tw := tar.NewWriter(gw)
|
||||
|
||||
add := func(name string, body []byte) error {
|
||||
hdr := &tar.Header{
|
||||
Name: name,
|
||||
Mode: 0o644,
|
||||
Size: int64(len(body)),
|
||||
ModTime: time.Now().UTC(),
|
||||
}
|
||||
if err := tw.WriteHeader(hdr); err != nil {
|
||||
return err
|
||||
}
|
||||
_, err := tw.Write(body)
|
||||
return err
|
||||
}
|
||||
|
||||
if err := add(manifestName, manifestJSON); err != nil {
|
||||
_ = tw.Close()
|
||||
_ = gw.Close()
|
||||
return nil, err
|
||||
}
|
||||
if err := add(sigFileName, sig); err != nil {
|
||||
_ = tw.Close()
|
||||
_ = gw.Close()
|
||||
return nil, err
|
||||
}
|
||||
i := 0
|
||||
for _, p := range keys {
|
||||
if err := add(p, payload[i]); err != nil {
|
||||
_ = tw.Close()
|
||||
_ = gw.Close()
|
||||
return nil, err
|
||||
}
|
||||
i++
|
||||
}
|
||||
if err := tw.Close(); err != nil {
|
||||
_ = gw.Close()
|
||||
return nil, err
|
||||
}
|
||||
if err := gw.Close(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return buf.Bytes(), nil
|
||||
}
|
||||
|
||||
func sortedStringKeys(m map[string]string) []string {
|
||||
keys := make([]string, 0, len(m))
|
||||
for k := range m {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
return keys
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
package bundle
|
||||
|
||||
import (
|
||||
"archive/tar"
|
||||
"bytes"
|
||||
"compress/gzip"
|
||||
"crypto/ed25519"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"path"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// VerifiedContents is the result of verifying a downloaded bundle on evobgp-node.
|
||||
type VerifiedContents struct {
|
||||
Manifest Manifest
|
||||
Files map[string][]byte
|
||||
}
|
||||
|
||||
// VerifyGzippedTar checks Ed25519 signature over manifest.json and SHA-256 of each file.
|
||||
func VerifyGzippedTar(bundle []byte, pub ed25519.PublicKey) (*VerifiedContents, error) {
|
||||
gr, err := gzip.NewReader(bytes.NewReader(bundle))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer gr.Close()
|
||||
|
||||
var manifestRaw []byte
|
||||
var sig []byte
|
||||
files := make(map[string][]byte)
|
||||
tr := tar.NewReader(gr)
|
||||
for {
|
||||
hdr, err := tr.Next()
|
||||
if err == io.EOF {
|
||||
break
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
name := path.Clean(hdr.Name)
|
||||
if strings.HasPrefix(name, "..") {
|
||||
return nil, fmt.Errorf("bundle: illegal tar entry %q", hdr.Name)
|
||||
}
|
||||
data, err := io.ReadAll(tr)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
switch name {
|
||||
case manifestName:
|
||||
manifestRaw = data
|
||||
case sigFileName:
|
||||
sig = data
|
||||
default:
|
||||
files[name] = data
|
||||
}
|
||||
}
|
||||
if len(manifestRaw) == 0 || len(sig) == 0 {
|
||||
return nil, fmt.Errorf("bundle: missing manifest or signature")
|
||||
}
|
||||
if !ed25519.Verify(pub, manifestRaw, sig) {
|
||||
return nil, fmt.Errorf("bundle: ed25519 signature mismatch")
|
||||
}
|
||||
var m Manifest
|
||||
if err := json.Unmarshal(manifestRaw, &m); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, fe := range m.Files {
|
||||
body, ok := files[fe.Path]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("bundle: missing file %q", fe.Path)
|
||||
}
|
||||
h := sha256.Sum256(body)
|
||||
if fmt.Sprintf("%x", h[:]) != fe.SHA256 {
|
||||
return nil, fmt.Errorf("bundle: checksum mismatch for %q", fe.Path)
|
||||
}
|
||||
}
|
||||
return &VerifiedContents{Manifest: m, Files: files}, nil
|
||||
}
|
||||
|
||||
// ParsePublicKeyHex decodes a 64-char hex Ed25519 public key (32 bytes).
|
||||
func ParsePublicKeyHex(s string) (ed25519.PublicKey, error) {
|
||||
s = strings.TrimSpace(strings.TrimPrefix(strings.TrimPrefix(s, "0x"), "0X"))
|
||||
raw, err := hex.DecodeString(s)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("bundle: hex decode: %w", err)
|
||||
}
|
||||
if len(raw) != ed25519.PublicKeySize {
|
||||
return nil, fmt.Errorf("bundle: want %d-byte ed25519 public key", ed25519.PublicKeySize)
|
||||
}
|
||||
return ed25519.PublicKey(raw), nil
|
||||
}
|
||||
|
||||
// PublicKeyFromManifestBase64 uses the key embedded in the signed manifest (cross-check with expected pubkey optional).
|
||||
func PublicKeyFromManifestBase64(m *Manifest) (ed25519.PublicKey, error) {
|
||||
if m.PublicKeyB64 == "" {
|
||||
return nil, fmt.Errorf("bundle: manifest missing public_key_base64")
|
||||
}
|
||||
raw, err := base64.StdEncoding.DecodeString(m.PublicKeyB64)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(raw) != ed25519.PublicKeySize {
|
||||
return nil, fmt.Errorf("bundle: bad public key length")
|
||||
}
|
||||
return ed25519.PublicKey(raw), nil
|
||||
}
|
||||
Reference in New Issue
Block a user