// SPDX-License-Identifier: Apache-2.0 // Package bundle defines the signed configuration bundle exchanged between the // management server and the agents (docs/adr/0009-signierte-config-bundles.md). // // A bundle is a JSON manifest, an Ed25519 signature over the exact manifest // bytes and the artifact contents. The server signs with [Sign]; the agent // checks with [Verify] before it parses or stores anything. package bundle import ( "bytes" "crypto/ed25519" "crypto/sha256" "encoding/hex" "encoding/json" "errors" "fmt" "path" "strings" "time" ) // FormatVersion is the manifest format produced by this package. const FormatVersion = 1 // Limits that keep a malicious or broken bundle from exhausting the agent. const ( MaxFiles = 100_000 MaxPathLen = 255 MaxTotalSize = 512 << 20 ) // Manifest describes one revision for one agent. type Manifest struct { Format int `json:"format"` AgentID string `json:"agent_id"` Role string `json:"role"` Revision uint64 `json:"revision"` CreatedAt time.Time `json:"created_at"` Files []File `json:"files"` } // File describes one artifact. type File struct { // Path is relative to the role's configuration root, slash separated // (see [CheckPath]). Path string `json:"path"` // Mode holds the Unix permission bits (e.g. 0o640). Mode uint32 `json:"mode"` // SHA256 is the lowercase hex digest of the content. SHA256 string `json:"sha256"` Size int64 `json:"size"` // CreateOnly files are written only if they do not exist yet and are // never overwritten or removed afterwards (initial files of dynamic // zones). CreateOnly bool `json:"create_only,omitempty"` } // Artifact is a file with its content, the input of [Sign]. type Artifact struct { Path string Mode uint32 Content []byte CreateOnly bool } // Sign builds the manifest for the artifacts and signs it. The returned // manifest bytes must be transmitted unchanged. func Sign(key ed25519.PrivateKey, agentID, role string, revision uint64, createdAt time.Time, arts []Artifact) (manifest, signature []byte, err error) { m := Manifest{ Format: FormatVersion, AgentID: agentID, Role: role, Revision: revision, CreatedAt: createdAt.UTC(), Files: make([]File, len(arts)), } for i, a := range arts { sum := sha256.Sum256(a.Content) m.Files[i] = File{ Path: a.Path, Mode: a.Mode, SHA256: hex.EncodeToString(sum[:]), Size: int64(len(a.Content)), CreateOnly: a.CreateOnly, } } if err := m.check(); err != nil { return nil, nil, err } manifest, err = json.Marshal(m) if err != nil { return nil, nil, fmt.Errorf("encode manifest: %w", err) } if len(key) != ed25519.PrivateKeySize { return nil, nil, errors.New("invalid signing key") } return manifest, ed25519.Sign(key, manifest), nil } // Expect lists what the verifier requires of a bundle. type Expect struct { AgentID string Role string // MinRevision rejects bundles with a revision lower than this value // (replay protection; 0 accepts every revision). MinRevision uint64 } // Verify checks the signature over the exact manifest bytes, then the // manifest itself (format, agent, role, revision, paths) and finally that // contents holds exactly the listed files with matching size and digest. // contents maps the manifest path to the file content. func Verify(pub ed25519.PublicKey, manifest, signature []byte, contents map[string][]byte, want Expect) (*Manifest, error) { if len(pub) != ed25519.PublicKeySize { return nil, errors.New("invalid bundle signing key") } if !ed25519.Verify(pub, manifest, signature) { return nil, errors.New("bundle signature is invalid") } var m Manifest dec := json.NewDecoder(bytes.NewReader(manifest)) dec.DisallowUnknownFields() if err := dec.Decode(&m); err != nil { return nil, fmt.Errorf("decode manifest: %w", err) } if dec.More() { return nil, errors.New("decode manifest: trailing data") } switch { case m.Format != FormatVersion: return nil, fmt.Errorf("unsupported manifest format %d", m.Format) case m.AgentID != want.AgentID: return nil, fmt.Errorf("bundle is for agent %q, not %q", m.AgentID, want.AgentID) case m.Role != want.Role: return nil, fmt.Errorf("bundle is for role %q, not %q", m.Role, want.Role) case m.Revision == 0: return nil, errors.New("bundle has no revision") case m.Revision < want.MinRevision: return nil, fmt.Errorf("bundle revision %d is older than %d", m.Revision, want.MinRevision) } if err := m.check(); err != nil { return nil, err } if len(contents) != len(m.Files) { return nil, fmt.Errorf("bundle has %d files, manifest lists %d", len(contents), len(m.Files)) } for _, f := range m.Files { c, ok := contents[f.Path] if !ok { return nil, fmt.Errorf("file %s is missing", f.Path) } if int64(len(c)) != f.Size { return nil, fmt.Errorf("file %s: size %d, manifest says %d", f.Path, len(c), f.Size) } sum := sha256.Sum256(c) if hex.EncodeToString(sum[:]) != f.SHA256 { return nil, fmt.Errorf("file %s: SHA-256 mismatch", f.Path) } } return &m, nil } // check validates the file list: count, total size, paths, duplicates and // modes. func (m *Manifest) check() error { if len(m.Files) > MaxFiles { return fmt.Errorf("too many files (%d)", len(m.Files)) } seen := make(map[string]bool, len(m.Files)) var total int64 for _, f := range m.Files { if err := CheckPath(f.Path); err != nil { return err } if seen[f.Path] { return fmt.Errorf("duplicate file %s", f.Path) } seen[f.Path] = true if f.Mode&^0o777 != 0 { return fmt.Errorf("file %s: invalid mode %o", f.Path, f.Mode) } if f.Size < 0 { return fmt.Errorf("file %s: negative size", f.Path) } total += f.Size if total > MaxTotalSize { return errors.New("bundle exceeds the size limit") } } return nil } // CheckPath accepts clean, relative, slash-separated paths of the characters // A-Z a-z 0-9 . _ - + @ whose elements do not start with a dot. Agents can // therefore join them to their root directory without escaping it. func CheckPath(p string) error { if p == "" || len(p) > MaxPathLen { return fmt.Errorf("invalid file path %q", p) } if path.IsAbs(p) || path.Clean(p) != p { return fmt.Errorf("file path %q is not clean and relative", p) } for _, el := range strings.Split(p, "/") { if el == "" || el[0] == '.' { return fmt.Errorf("invalid element in file path %q", p) } for i := 0; i < len(el); i++ { c := el[i] if (c < 'a' || c > 'z') && (c < 'A' || c > 'Z') && (c < '0' || c > '9') && c != '.' && c != '_' && c != '-' && c != '+' && c != '@' { return fmt.Errorf("invalid character in file path %q", p) } } } return nil }