Files
DDI/internal/bundle/bundle.go
T
beug.k@beugsiud.deandClaude Opus 5.5 e843502cbf feat: M3 contracts for agents, revisions and bundles
Shared Apache-2.0 packages for the signed bundle format and the agent
certificate conventions, the agent role interface, REST API spec for
agents, enrollment tokens, revisions, deployments and leases, and ADR
0021. Generator settings now hold the target paths on the managed hosts.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 14:54:30 +02:00

212 lines
6.6 KiB
Go

// 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
}