package bundle import ( "archive/tar" "bytes" "compress/gzip" "crypto/ed25519" "crypto/sha256" "encoding/base64" "encoding/json" "fmt" "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) { 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) seen := map[string]bool{} var total int64 for p, content := range fragments { 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 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 } if int64(len(manifestJSON)) > l.MaxManifestBytes { return nil, ErrLimit } sig := ed25519.Sign(priv, manifestJSON) var buf bytes.Buffer 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{ 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 }