feat(httpapi): harden control plane and speed up prefix refresh

Очередь job получила lease и fencing, телеметрия и прогресс deploy сохраняются, ревизия пишет manifest входов. Обновление префиксов не переписывает неизменённый снимок, большие наборы идут через COPY, а устаревшая ревизия не попадает в deploy. Бандл ноды кэшируется, prune и постановка job больше не держат горячий путь.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
DenozordecandCursor committed 2026-11-02 17:23:59 +07:00
1 parent 924f111e48
commit f8e0520817
197 files changed
+18655 -1416

No files matched your search

+77 -11
View File
@@ -3,34 +3,100 @@ 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 {
return WriteExtractedFilesWithLimits(root, v, Limits{})
}
// WriteExtractedFilesWithLimits confines all filesystem operations to an open
// directory handle. Replacing existing files uses unlink + exclusive creation,
// so existing hard links and symlinks cannot redirect writes outside the root.
func WriteExtractedFilesWithLimits(root string, v *VerifiedContents, limits Limits) error {
l, err := limits.Normalized()
if err != nil {
return err
}
if v == nil {
return fmt.Errorf("bundle: nil verified contents")
}
if len(v.Manifest.Files) > l.MaxEntries-2 {
return ErrLimit
}
seen := map[string]bool{}
var total int64
for _, fe := range v.Manifest.Files {
if err := validatePath(fe.Path, l.MaxPathBytes); err != nil {
return err
}
if seen[strings.ToLower(fe.Path)] {
return fmt.Errorf("bundle: duplicate path %q", fe.Path)
}
seen[strings.ToLower(fe.Path)] = true
data, ok := v.Files[fe.Path]
if !ok {
return fmt.Errorf("bundle: missing file %q", fe.Path)
}
if int64(len(data)) > l.MaxFileBytes || int64(len(data)) > l.MaxExpandedBytes-total {
return ErrLimit
}
total += int64(len(data))
}
root = filepath.Clean(root)
if err := os.MkdirAll(root, 0o755); err != nil {
return err
}
confined, err := os.OpenRoot(root)
if err != nil {
return err
}
defer func() { _ = confined.Close() }()
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)
parts := strings.Split(fe.Path, "/")
for i := 1; i < len(parts); i++ {
dir := filepath.FromSlash(strings.Join(parts[:i], "/"))
if err := confined.Mkdir(dir, 0o755); err != nil && !os.IsExist(err) {
return err
}
info, err := confined.Lstat(dir)
if err != nil {
return err
}
if !info.IsDir() || info.Mode()&os.ModeSymlink != 0 {
return fmt.Errorf("bundle: unsafe directory %q", dir)
}
}
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 {
dest := filepath.FromSlash(fe.Path)
info, err := confined.Lstat(dest)
if err == nil {
if !info.Mode().IsRegular() {
return fmt.Errorf("bundle: unsafe destination %q", dest)
}
if err = confined.Remove(dest); err != nil {
return err
}
} else if !os.IsNotExist(err) {
return err
}
if err := os.WriteFile(dest, data, 0o644); err != nil {
f, err := confined.OpenFile(dest, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o644)
if err != nil {
return err
}
_, writeErr := f.Write(data)
closeErr := f.Close()
if writeErr != nil {
return writeErr
}
if closeErr != nil {
return closeErr
}
}
return nil
}
@@ -0,0 +1,120 @@
package bundle
import (
"archive/tar"
"bytes"
"compress/gzip"
"crypto/ed25519"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"encoding/json"
"io"
"os"
"reflect"
"sort"
"testing"
)
// legacyManifestV1 mirrors the decoder fields present in the locally available
// v1.0.0 source. It deliberately has no future capability or deployment fields.
type legacyManifestV1 struct {
RevisionID string `json:"revision_id"`
SpeakerID string `json:"speaker_id"`
CreatedAt string `json:"created_at"`
Files []struct {
Path string `json:"path"`
SHA256 string `json:"sha256"`
} `json:"files"`
Algorithm string `json:"signature_algorithm"`
PublicKeyBase64 string `json:"public_key_base64"`
}
func TestLegacyCompatibilityFrozenSignedCurrentBaseline(t *testing.T) {
raw, err := os.ReadFile("testdata/current_baseline_signed_bundle.tar.gz")
if err != nil {
t.Fatal(err)
}
pub := ed25519.NewKeyFromSeed(bytes.Repeat([]byte{0x42}, ed25519.SeedSize)).Public().(ed25519.PublicKey)
verified, err := VerifyGzippedTar(raw, pub)
if err != nil {
t.Fatalf("frozen signed baseline rejected: %v", err)
}
if verified.Manifest.RevisionID != "revision-legacy" || verified.Manifest.SpeakerID != "speaker-legacy" || len(verified.Files) != 2 {
t.Fatalf("frozen baseline contents: %+v", verified.Manifest)
}
}
func TestLegacyCompatibilityBundleWire(t *testing.T) {
seed := bytes.Repeat([]byte{0x42}, ed25519.SeedSize)
priv := ed25519.NewKeyFromSeed(seed)
pub := priv.Public().(ed25519.PublicKey)
fragments := map[string]string{
"bird.conf": "router id 192.0.2.1;\n",
"bird.d/evobgp_prefixes_v4.conf": "# revision\n",
"_preview": "must not ship",
}
raw, err := BuildGzippedTar("revision-legacy", "speaker-legacy", fragments, priv)
if err != nil {
t.Fatal(err)
}
gr, err := gzip.NewReader(bytes.NewReader(raw))
if err != nil {
t.Fatal(err)
}
defer func() { _ = gr.Close() }()
tr := tar.NewReader(gr)
entries := map[string][]byte{}
var order []string
for {
h, err := tr.Next()
if err == io.EOF {
break
}
if err != nil {
t.Fatal(err)
}
body, err := io.ReadAll(tr)
if err != nil {
t.Fatal(err)
}
order = append(order, h.Name)
entries[h.Name] = body
}
wantOrder := []string{"manifest.json", "bundle.sig", "bird.conf", "bird.d/evobgp_prefixes_v4.conf"}
if !reflect.DeepEqual(order, wantOrder) {
t.Fatalf("legacy tar layout: got %v, want %v", order, wantOrder)
}
var top map[string]json.RawMessage
if err := json.Unmarshal(entries["manifest.json"], &top); err != nil {
t.Fatal(err)
}
wantFields := []string{"created_at", "files", "public_key_base64", "revision_id", "signature_algorithm", "speaker_id"}
fields := make([]string, 0, len(top))
for k := range top {
fields = append(fields, k)
}
sort.Strings(fields)
if !reflect.DeepEqual(fields, wantFields) {
t.Fatalf("manifest fields: got %v, want %v", fields, wantFields)
}
var m legacyManifestV1
if err := json.Unmarshal(entries["manifest.json"], &m); err != nil {
t.Fatal(err)
}
if m.RevisionID != "revision-legacy" || m.SpeakerID != "speaker-legacy" || m.CreatedAt == "" || m.Algorithm != "ed25519" || m.PublicKeyBase64 != base64.StdEncoding.EncodeToString(pub) {
t.Fatalf("legacy manifest fields: %+v", m)
}
if !ed25519.Verify(pub, entries["manifest.json"], entries["bundle.sig"]) {
t.Fatal("legacy Ed25519 verifier rejected current signed manifest")
}
if len(m.Files) != 2 {
t.Fatalf("legacy manifest file count: %d", len(m.Files))
}
for _, f := range m.Files {
sum := sha256.Sum256(entries[f.Path])
if hex.EncodeToString(sum[:]) != f.SHA256 {
t.Fatalf("legacy SHA-256 mismatch for %q", f.Path)
}
}
}
+103
View File
@@ -0,0 +1,103 @@
package bundle
import (
"errors"
"fmt"
"io"
"path"
"strings"
)
// ErrLimit reports that a transport, archive or extraction budget was exceeded.
var ErrLimit = errors.New("bundle: resource limit exceeded")
// Limits bounds both archive transport and decoded tar data, including metadata.
// Zero fields select defaults. These ceilings permit large (1M-prefix) configs;
// they are safety ceilings, not a promise of suitability for a small-RAM node.
type Limits struct {
MaxCompressedBytes int64
MaxExpandedBytes int64
MaxFileBytes int64
MaxManifestBytes int64
MaxEntries int
MaxPathBytes int
}
// DefaultLimits permits large production configs while bounding untrusted input.
func DefaultLimits() Limits {
return Limits{MaxCompressedBytes: 128 << 20, MaxExpandedBytes: 512 << 20, MaxFileBytes: 256 << 20, MaxManifestBytes: 2 << 20, MaxEntries: 4096, MaxPathBytes: 1024}
}
// Normalized fills zero fields with defaults and rejects invalid budgets.
func (l Limits) Normalized() (Limits, error) {
d := DefaultLimits()
values := []struct {
v *int64
fallback int64
}{{&l.MaxCompressedBytes, d.MaxCompressedBytes}, {&l.MaxExpandedBytes, d.MaxExpandedBytes}, {&l.MaxFileBytes, d.MaxFileBytes}, {&l.MaxManifestBytes, d.MaxManifestBytes}}
for _, x := range values {
if *x.v < 0 || *x.v == int64(^uint64(0)>>1) {
return l, fmt.Errorf("bundle: invalid byte limit")
}
if *x.v == 0 {
*x.v = x.fallback
}
}
if l.MaxEntries < 0 || l.MaxPathBytes < 0 {
return l, fmt.Errorf("bundle: invalid entry/path limit")
}
if l.MaxEntries == 0 {
l.MaxEntries = d.MaxEntries
}
if l.MaxPathBytes == 0 {
l.MaxPathBytes = d.MaxPathBytes
}
return l, nil
}
func validatePath(name string, limit int) error {
if name == "" || len(name) > limit || path.IsAbs(name) || path.Clean(name) != name || name == "." || strings.ContainsAny(name, "\\:\x00") {
return fmt.Errorf("bundle: unsafe path %q", name)
}
for _, part := range strings.Split(name, "/") {
if part == ".." || strings.TrimRight(part, " .") != part {
return fmt.Errorf("bundle: unsafe path %q", name)
}
base := strings.ToUpper(strings.SplitN(part, ".", 2)[0])
if base == "CON" || base == "PRN" || base == "AUX" || base == "NUL" || (len(base) == 4 && (strings.HasPrefix(base, "COM") || strings.HasPrefix(base, "LPT")) && base[3] >= '1' && base[3] <= '9') {
return fmt.Errorf("bundle: reserved path %q", name)
}
}
return nil
}
type limitedWriter struct {
w io.Writer
remaining int64
}
func (w *limitedWriter) Write(p []byte) (int, error) {
if int64(len(p)) > w.remaining {
return 0, ErrLimit
}
n, err := w.w.Write(p)
w.remaining -= int64(n)
return n, err
}
// ReadCompressed rejects oversized input while reading, including chunked HTTP
// bodies and local files whose size changes after stat.
func ReadCompressed(r io.Reader, limits Limits) ([]byte, error) {
l, err := limits.Normalized()
if err != nil {
return nil, err
}
b, err := io.ReadAll(io.LimitReader(r, l.MaxCompressedBytes+1))
if err != nil {
return nil, err
}
if int64(len(b)) > l.MaxCompressedBytes {
return nil, ErrLimit
}
return b, nil
}
+286
View File
@@ -0,0 +1,286 @@
package bundle
import (
"archive/tar"
"bytes"
"compress/gzip"
"crypto/ed25519"
"errors"
"fmt"
"io"
"os"
"path/filepath"
"runtime"
"strings"
"testing"
)
type testEntry struct {
header tar.Header
body []byte
}
func archiveEntries(t *testing.T, entries []testEntry) []byte {
t.Helper()
var b bytes.Buffer
gz := gzip.NewWriter(&b)
tw := tar.NewWriter(gz)
for _, entry := range entries {
h := entry.header
if h.Typeflag == 0 {
h.Typeflag = tar.TypeReg
}
h.Size = int64(len(entry.body))
if err := tw.WriteHeader(&h); err != nil {
t.Fatal(err)
}
if _, err := tw.Write(entry.body); err != nil {
t.Fatal(err)
}
}
if err := tw.Close(); err != nil {
t.Fatal(err)
}
if err := gz.Close(); err != nil {
t.Fatal(err)
}
return b.Bytes()
}
func testKey() ed25519.PrivateKey {
return ed25519.NewKeyFromSeed(bytes.Repeat([]byte{7}, ed25519.SeedSize))
}
func TestBundleResourceLimits(t *testing.T) {
pub := testKey().Public().(ed25519.PublicKey)
cases := []struct {
name string
entries []testEntry
limits Limits
}{
{"file", []testEntry{{header: tar.Header{Name: "bird.conf"}, body: bytes.Repeat([]byte{'a'}, 1025)}}, Limits{MaxFileBytes: 1024}},
{"expanded", []testEntry{{header: tar.Header{Name: "bird.conf"}, body: bytes.Repeat([]byte{'a'}, 8192)}}, Limits{MaxExpandedBytes: 4096}},
{"manifest", []testEntry{{header: tar.Header{Name: manifestName}, body: bytes.Repeat([]byte{'a'}, 1025)}}, Limits{MaxManifestBytes: 1024}},
{"entries", []testEntry{{header: tar.Header{Name: "a"}}, {header: tar.Header{Name: "b"}}, {header: tar.Header{Name: "c"}}}, Limits{MaxEntries: 2}},
{"pax-metadata", []testEntry{{header: tar.Header{Name: "bird.conf", Format: tar.FormatPAX, PAXRecords: map[string]string{"comment": strings.Repeat("a", 8192)}}}}, Limits{MaxExpandedBytes: 4096}},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
raw := archiveEntries(t, tc.entries)
if _, err := VerifyGzippedTarWithLimits(raw, pub, tc.limits); !errors.Is(err, ErrLimit) {
t.Fatalf("want limit, got %v", err)
}
})
}
if _, err := VerifyGzippedTarWithLimits(make([]byte, 65), pub, Limits{MaxCompressedBytes: 64}); !errors.Is(err, ErrLimit) {
t.Fatalf("compressed cap: %v", err)
}
}
func TestBundleRejectsUnsafeEntries(t *testing.T) {
pub := testKey().Public().(ed25519.PublicKey)
for _, name := range []string{"../escape", "/etc/bird.conf", `bird.d\escape`, "C:/escape", "bird.d/../escape", "a//b", "a/./b", "NUL", "bird.conf:stream", "a. /b"} {
t.Run(name, func(t *testing.T) {
raw := archiveEntries(t, []testEntry{{header: tar.Header{Name: name}, body: []byte("x")}})
if _, err := VerifyGzippedTar(raw, pub); err == nil {
t.Fatal("unsafe path accepted")
}
})
}
for _, kind := range []byte{tar.TypeSymlink, tar.TypeLink, tar.TypeDir, tar.TypeFifo} {
raw := archiveEntries(t, []testEntry{{header: tar.Header{Name: "bird.conf", Typeflag: kind, Linkname: "outside"}}})
if _, err := VerifyGzippedTar(raw, pub); err == nil {
t.Fatalf("type %d accepted", kind)
}
}
raw := archiveEntries(t, []testEntry{{header: tar.Header{Name: "bird.conf"}}, {header: tar.Header{Name: "bird.conf"}}})
if _, err := VerifyGzippedTar(raw, pub); err == nil || !strings.Contains(err.Error(), "duplicate") {
t.Fatalf("duplicate accepted: %v", err)
}
}
func TestBundleChecksTrailerAndTrailingExpansion(t *testing.T) {
priv := testKey()
pub := priv.Public().(ed25519.PublicKey)
raw, err := BuildGzippedTar("r", "s", map[string]string{"bird.conf": "router id 192.0.2.1;"}, priv)
if err != nil {
t.Fatal(err)
}
corrupt := append([]byte{}, raw...)
corrupt[len(corrupt)-8] ^= 0xff
if _, err = VerifyGzippedTar(corrupt, pub); err == nil {
t.Fatal("gzip checksum ignored after tar EOF")
}
var extra bytes.Buffer
gz := gzip.NewWriter(&extra)
if _, err = gz.Write(bytes.Repeat([]byte{'x'}, 32<<10)); err != nil {
t.Fatal(err)
}
if err = gz.Close(); err != nil {
t.Fatal(err)
}
joined := append(append([]byte{}, raw...), extra.Bytes()...)
if _, err = VerifyGzippedTarWithLimits(joined, pub, Limits{MaxExpandedBytes: 8 << 10}); !errors.Is(err, ErrLimit) {
t.Fatalf("trailing gzip member bypassed budget: %v", err)
}
}
func TestBundleRejectsPAXSparseRegularFile(t *testing.T) {
// archive/tar expands PAX sparse holes even though the returned type is a
// regular file. Build a physical one-byte entry with a 4096-byte logical size.
record := func(key, value string) string {
body := key + "=" + value + "\n"
n := len(body) + 2
for {
next := len(fmt.Sprint(n)) + 1 + len(body)
if next == n {
return fmt.Sprintf("%d %s", n, body)
}
n = next
}
}
pax := record("GNU.sparse.size", "4096") + record("GNU.sparse.numblocks", "1") + record("GNU.sparse.map", "0,1")
var physical bytes.Buffer
tw := tar.NewWriter(&physical)
for _, entry := range []testEntry{{header: tar.Header{Name: "PaxHeader", Size: int64(len(pax))}, body: []byte(pax)}, {header: tar.Header{Name: "bird.conf", Size: 1}, body: []byte("x")}} {
if err := tw.WriteHeader(&entry.header); err != nil {
t.Fatal(err)
}
if _, err := tw.Write(entry.body); err != nil {
t.Fatal(err)
}
}
if err := tw.Close(); err != nil {
t.Fatal(err)
}
data := physical.Bytes()
data[156] = tar.TypeXHeader
for i := 148; i < 156; i++ {
data[i] = ' '
}
sum := 0
for _, b := range data[:512] {
sum += int(b)
}
copy(data[148:156], fmt.Sprintf("%06o\x00 ", sum))
h, err := tar.NewReader(bytes.NewReader(data)).Next()
if err != nil || h.Typeflag != tar.TypeReg || h.Size != 4096 {
t.Fatalf("invalid sparse regression fixture %+v %v", h, err)
}
var compressed bytes.Buffer
gz := gzip.NewWriter(&compressed)
if _, err = gz.Write(data); err != nil {
t.Fatal(err)
}
if err = gz.Close(); err != nil {
t.Fatal(err)
}
if _, err = VerifyGzippedTar(compressed.Bytes(), testKey().Public().(ed25519.PublicKey)); err == nil || !strings.Contains(err.Error(), "sparse") {
t.Fatalf("PAX sparse accepted: %v", err)
}
}
func TestBundleRejectsUnmanifestedPayload(t *testing.T) {
priv := testKey()
raw, err := BuildGzippedTar("r", "s", map[string]string{"bird.conf": "x"}, priv)
if err != nil {
t.Fatal(err)
}
gr, err := gzip.NewReader(bytes.NewReader(raw))
if err != nil {
t.Fatal(err)
}
defer func() { _ = gr.Close() }()
tr := tar.NewReader(gr)
var entries []testEntry
for {
h, e := tr.Next()
if e == io.EOF {
break
}
if e != nil {
t.Fatal(e)
}
b, e := io.ReadAll(tr)
if e != nil {
t.Fatal(e)
}
entries = append(entries, testEntry{header: *h, body: b})
}
entries = append(entries, testEntry{header: tar.Header{Name: "unsigned.conf"}, body: []byte("unsigned")})
if _, err = VerifyGzippedTar(archiveEntries(t, entries), priv.Public().(ed25519.PublicKey)); err == nil {
t.Fatal("unmanifested payload accepted")
}
}
func TestExtractionDoesNotFollowLinks(t *testing.T) {
root := t.TempDir()
outside := filepath.Join(t.TempDir(), "outside")
if err := os.WriteFile(outside, []byte("preserve"), 0o600); err != nil {
t.Fatal(err)
}
dest := filepath.Join(root, "bird.conf")
if err := os.Link(outside, dest); err != nil {
if runtime.GOOS == "linux" {
t.Fatalf("Linux link regression requires hard links: %v", err)
}
t.Skipf("hard links unavailable: %v", err)
}
v := &VerifiedContents{Manifest: Manifest{Files: []FileEntry{{Path: "bird.conf"}}}, Files: map[string][]byte{"bird.conf": []byte("replacement")}}
if err := WriteExtractedFiles(root, v); err != nil {
t.Fatal(err)
}
got, err := os.ReadFile(outside)
if err != nil || string(got) != "preserve" {
t.Fatalf("hard link modified outside: %s %v", got, err)
}
if err = os.Remove(dest); err != nil {
t.Fatal(err)
}
if err = os.Symlink(outside, dest); err != nil {
if runtime.GOOS == "linux" {
t.Fatalf("Linux link regression requires symlinks: %v", err)
}
t.Skipf("symlinks unavailable: %v", err)
}
if err = WriteExtractedFiles(root, v); err == nil {
t.Fatal("symlink destination accepted")
}
got, err = os.ReadFile(outside)
if err != nil || string(got) != "preserve" {
t.Fatal("symlink modified outside")
}
}
func TestBundleBuilderBoundsAndPortablePaths(t *testing.T) {
for _, name := range []string{"/bird.conf", "a/../bird.conf", manifestName, sigFileName, `bird.d\bad`} {
if _, err := BuildGzippedTar("r", "s", map[string]string{name: "x"}, testKey()); err == nil {
t.Fatalf("invalid builder path accepted: %q", name)
}
}
if _, err := BuildGzippedTarWithLimits("r", "s", map[string]string{"bird.conf": strings.Repeat("x", 1025)}, testKey(), Limits{MaxFileBytes: 1024}); !errors.Is(err, ErrLimit) {
t.Fatalf("builder file cap %v", err)
}
if _, err := BuildGzippedTarWithLimits("r", "s", map[string]string{"bird.conf": "x"}, testKey(), Limits{MaxExpandedBytes: 1024}); !errors.Is(err, ErrLimit) {
t.Fatalf("builder tar overhead cap %v", err)
}
}
// This is a decoded-size regression using repeated representative route lines,
// not a unique-prefix cardinality test or a BIRD validity/performance benchmark.
func TestDefaultLimitsAllowMillionRepresentativeIPv6RouteLines(t *testing.T) {
if testing.Short() {
t.Skip("large representative config")
}
config := strings.Repeat("route 2001:db8:1234:5678::/64 blackhole;\n", 1_000_000)
raw, err := BuildGzippedTar("large", "speaker", map[string]string{"bird.conf": config}, testKey())
if err != nil {
t.Fatal(err)
}
v, err := VerifyGzippedTar(raw, testKey().Public().(ed25519.PublicKey))
if err != nil {
t.Fatal(err)
}
if len(v.Files["bird.conf"]) != len(config) {
t.Fatal("large config truncated")
}
}
+35 -8
View File
@@ -9,7 +9,6 @@ import (
"encoding/base64"
"encoding/json"
"fmt"
"path"
"sort"
"strings"
"time"
@@ -36,22 +35,47 @@ 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) {
return BuildGzippedTarWithLimits(revisionID, speakerID, fragments, priv, Limits{})
}
// BuildGzippedTarWithLimits preserves the manifest/signature wire format while
// refusing payloads that exceed the receiving node's configured budgets.
func BuildGzippedTarWithLimits(revisionID, speakerID string, fragments map[string]string, priv ed25519.PrivateKey, limits Limits) ([]byte, error) {
l, err := limits.Normalized()
if err != nil {
return nil, err
}
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))
norm := make(map[string]string)
seen := map[string]bool{}
var total int64
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)
}
if strings.HasPrefix(p, "_") {
continue // UI-only preview aggregate, not shipped to nodes
}
if len(norm) >= l.MaxEntries-2 {
return nil, ErrLimit
}
if err := validatePath(p, l.MaxPathBytes); err != nil {
return nil, err
}
if p == manifestName || p == sigFileName || seen[strings.ToLower(p)] {
return nil, fmt.Errorf("bundle: duplicate or reserved path %q", p)
}
seen[strings.ToLower(p)] = true
if int64(len(content)) > l.MaxFileBytes || int64(len(content)) > l.MaxExpandedBytes-total {
return nil, ErrLimit
}
total += int64(len(content))
norm[p] = content
}
if len(norm) > l.MaxEntries-2 {
return nil, ErrLimit
}
var files []FileEntry
var payload [][]byte
@@ -75,11 +99,14 @@ func BuildGzippedTar(revisionID, speakerID string, fragments map[string]string,
if err != nil {
return nil, err
}
if int64(len(manifestJSON)) > l.MaxManifestBytes {
return nil, ErrLimit
}
sig := ed25519.Sign(priv, manifestJSON)
var buf bytes.Buffer
gw := gzip.NewWriter(&buf)
tw := tar.NewWriter(gw)
gw := gzip.NewWriter(&limitedWriter{w: &buf, remaining: l.MaxCompressedBytes})
tw := tar.NewWriter(&limitedWriter{w: gw, remaining: l.MaxExpandedBytes})
add := func(name string, body []byte) error {
hdr := &tar.Header{
Binary file not shown.
+92 -18
View File
@@ -11,44 +11,95 @@ import (
"encoding/json"
"fmt"
"io"
"path"
"strings"
)
// VerifiedContents is the result of verifying a downloaded bundle on evobgp-node.
// VerifiedContents contains only payloads covered by a verified signed manifest.
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))
// VerifyGzippedTar verifies the unchanged signed manifest format with default limits.
func VerifyGzippedTar(raw []byte, pub ed25519.PublicKey) (*VerifiedContents, error) {
return VerifyGzippedTarWithLimits(raw, pub, Limits{})
}
// VerifyGzippedTarWithLimits verifies the signature and hashes under explicit
// compressed, expanded, per-entry and metadata budgets.
func VerifyGzippedTarWithLimits(raw []byte, pub ed25519.PublicKey, limits Limits) (*VerifiedContents, error) {
l, err := limits.Normalized()
if err != nil {
return nil, err
}
if int64(len(raw)) > l.MaxCompressedBytes {
return nil, ErrLimit
}
if len(pub) != ed25519.PublicKeySize {
return nil, fmt.Errorf("bundle: invalid public key size")
}
gr, err := gzip.NewReader(bytes.NewReader(raw))
if err != nil {
return nil, err
}
defer func() { _ = gr.Close() }()
var manifestRaw []byte
var sig []byte
files := make(map[string][]byte)
tr := tar.NewReader(gr)
decoded := &io.LimitedReader{R: gr, N: l.MaxExpandedBytes + 1}
tr := tar.NewReader(decoded)
var manifestRaw, sig []byte
files := map[string][]byte{}
seen := map[string]bool{}
count := 0
var logicalBytes int64
for {
hdr, err := tr.Next()
if decoded.N == 0 {
return nil, ErrLimit
}
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)
count++
if count > l.MaxEntries {
return nil, ErrLimit
}
data, err := io.ReadAll(tr)
if err = validatePath(hdr.Name, l.MaxPathBytes); err != nil {
return nil, err
}
name := hdr.Name
folded := strings.ToLower(name)
if seen[folded] {
return nil, fmt.Errorf("bundle: duplicate tar entry %q", name)
}
seen[folded] = true
if hdr.Typeflag != tar.TypeReg && hdr.Typeflag != tar.TypeRegA {
return nil, fmt.Errorf("bundle: nonregular tar entry %q", name)
}
for key := range hdr.PAXRecords {
if strings.HasPrefix(key, "GNU.sparse.") {
return nil, fmt.Errorf("bundle: sparse entry %q is unsupported", name)
}
}
max := l.MaxFileBytes
if name == manifestName {
max = l.MaxManifestBytes
}
if name == sigFileName {
max = ed25519.SignatureSize
}
if hdr.Size < 0 || hdr.Size > max || hdr.Size >= decoded.N || hdr.Size > l.MaxExpandedBytes-logicalBytes {
return nil, ErrLimit
}
logicalBytes += hdr.Size
data, err := io.ReadAll(io.LimitReader(tr, max+1))
if err != nil {
return nil, err
}
if int64(len(data)) > max || decoded.N == 0 {
return nil, ErrLimit
}
switch name {
case manifestName:
manifestRaw = data
@@ -58,17 +109,36 @@ func VerifyGzippedTar(bundle []byte, pub ed25519.PublicKey) (*VerifiedContents,
files[name] = data
}
}
if len(manifestRaw) == 0 || len(sig) == 0 {
// tar EOF precedes the gzip trailer. Drain through the same byte budget so
// checksums, extra gzip members and trailing decompression are also bounded.
if _, err = io.Copy(io.Discard, decoded); err != nil {
return nil, err
}
if decoded.N == 0 {
return nil, ErrLimit
}
if len(manifestRaw) == 0 || len(sig) != ed25519.SignatureSize {
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 {
if err = json.Unmarshal(manifestRaw, &m); err != nil {
return nil, err
}
if len(m.Files) > l.MaxEntries-2 {
return nil, ErrLimit
}
listed := map[string]bool{}
for _, fe := range m.Files {
if err = validatePath(fe.Path, l.MaxPathBytes); err != nil {
return nil, err
}
if fe.Path == manifestName || fe.Path == sigFileName || listed[strings.ToLower(fe.Path)] {
return nil, fmt.Errorf("bundle: duplicate or reserved manifest path %q", fe.Path)
}
listed[strings.ToLower(fe.Path)] = true
body, ok := files[fe.Path]
if !ok {
return nil, fmt.Errorf("bundle: missing file %q", fe.Path)
@@ -78,10 +148,13 @@ func VerifyGzippedTar(bundle []byte, pub ed25519.PublicKey) (*VerifiedContents,
return nil, fmt.Errorf("bundle: checksum mismatch for %q", fe.Path)
}
}
if len(listed) != len(files) {
return nil, fmt.Errorf("bundle: payload is not covered by manifest")
}
return &VerifiedContents{Manifest: m, Files: files}, nil
}
// ParsePublicKeyHex decodes a 64-char hex Ed25519 public key (32 bytes).
// ParsePublicKeyHex decodes a 32-byte Ed25519 public key from hexadecimal.
func ParsePublicKeyHex(s string) (ed25519.PublicKey, error) {
s = strings.TrimSpace(strings.TrimPrefix(strings.TrimPrefix(s, "0x"), "0X"))
raw, err := hex.DecodeString(s)
@@ -94,7 +167,8 @@ func ParsePublicKeyHex(s string) (ed25519.PublicKey, error) {
return ed25519.PublicKey(raw), nil
}
// PublicKeyFromManifestBase64 uses the key embedded in the signed manifest (cross-check with expected pubkey optional).
// PublicKeyFromManifestBase64 decodes the manifest key. Callers must still trust
// an independently provisioned key when authenticating the bundle.
func PublicKeyFromManifestBase64(m *Manifest) (ed25519.PublicKey, error) {
if m.PublicKeyB64 == "" {
return nil, fmt.Errorf("bundle: manifest missing public_key_base64")