provenance

package
v0.0.0-...-2769864 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Oct 6, 2026 License: Apache-2.0 Imports: 12 Imported by: 0

README

provenance — signing and verifying append-only entries

Per-publisher Ed25519 signatures over a canonical entry digest, hash-chained per (scope, publisher) so gaps, reordering, fabrication, modification and truncation are all detectable.

File Role
digest.go The canonical entry digest — what actually gets signed
signer.go Signs an entry: publisher, keyID, monotonic seq, prevHash, signature
signingmem.go A memory.Memory decorator that signs append-only writes on the way through
verifier.go Offline chain verification — what oap audit verify walks
writeverify.go Verify-on-write: the facade's rejection of unsigned or forged append-only writes
chainheads.go The tail anchors recorded at session completion

Key custody

Publisher Key source Trust root
A session The AgentSession reconciler mints a per-session keypair into the per-session Secret (audit-signing-key) AgentSession.status.auditPublicKey / auditKeyID — witnessed by Kubernetes
A component (channelsd / authzd / operator) Minted at startup Registered via POST /memory/_publisher_key into the publisher-keys ConfigMap — see ../publisherkeys/

Chain heads land on AgentSession.status.auditChainHeads at completion; that tail anchor is what makes truncation detectable, not just modification.

Non-obvious constraints

  • Verification is on the write path, not only on audit. The operator's facade rejects unsigned or forged append-only writes from token callers and from in-process writers. This is not an after-the-fact check.
  • The digest is canonical, not byte-literal. A postgres round trip re-emits content from a JSONB column and created_at from a TIMESTAMPTZ one, so the stored bytes are never identical to what was written. Comparisons and signatures both work on the canonical form.
  • oap audit verify <session> exits non-zero on hard findings and runs entirely offline against the recorded public keys.

Documentation

Overview

Package provenance signs and verifies append-only memory entries: per-publisher Ed25519 signatures over a canonical entry digest, hash-chained per (scope, publisher) for gap/truncation detection.

Index

Constants

This section is empty.

Variables

View Source
var ErrEntryAppendedDispatchPut = errors.New("signingmem: refusing an append-only Put from inside an entry-appended dispatch")

ErrEntryAppendedDispatchPut is returned when an append-only Put reaches a SigningMemory from inside its own SignalEntryAppended dispatch — a ScopeHooks reaction Put()ing back through the same wrapper that raised the signal. See the comment inside Put for why this cannot be performed safely on the calling goroutine.

Functions

func ComputeChainHeads

func ComputeChainHeads(ctx context.Context, mem memory.Memory, scope memory.Scope) (map[string]ChainHead, error)

ComputeChainHeads scans every append-only kind in scope and returns, per publisher, the highest-seq entry's chain head {Seq, EntryDigest(entry)}. It anchors tail-truncation detection for ended sessions.

Entries with nil Provenance (unsigned writers) attribute to no publisher and are skipped — they anchor nothing. The per-publisher max is taken ACROSS all append-only kinds, matching the Signer's per-scope (not per-kind) chain.

func DecodePubKey

func DecodePubKey(b64 string) (ed25519.PublicKey, error)

DecodePubKey decodes a standard-base64 Ed25519 public key, validating it is exactly ed25519.PublicKeySize bytes. Delegates to pkg/x/keyid; see KeyID.

func EntryDigest

func EntryDigest(e memory.Entry) string

EntryDigest returns the hex SHA-256 of the canonical serialization of e: scope, kind, id, createdAt (UTC RFC3339Nano), sorted tags, links, content, publisher, keyID, seq, prevHash — everything except the signature. It is BOTH the chain link and the signed message.

func KeyID

func KeyID(pub ed25519.PublicKey) string

KeyID returns the key identifier for a public key: hex of the first 16 bytes of its SHA-256.

The derivation lives in pkg/x/keyid so a package can name a key without importing this one — importing this one confers the ability to SIGN, which pkg/web/webui/chat is guarded against acquiring at any depth.

func SessionPublisher

func SessionPublisher(namespace, name string) string

SessionPublisher returns the provenance publisher string for a session's signed audit entries: "session:<ns>/<name>". Controller (key registration) and runner (signing) MUST derive it identically.

func VerifyEntrySignature

func VerifyEntrySignature(keys PublisherKeyLookup, e memory.Entry) error

VerifyEntrySignature checks one entry's provenance envelope on its own: the envelope is present, a key is registered for the (publisher, keyID) it claims, and the signature verifies over the entry digest. It says NOTHING about chain position — no seq, no prevHash — so it answers "did this publisher sign this payload?" and not "does this entry belong where it sits". Use VerifyChain for the latter.

It is the check a reader owes any entry whose CONTENT it is about to act on (a witnessed key binding, for one) and the same check WriteVerifier applies at the door, so the two cannot drift apart.

Types

type ChainHead

type ChainHead struct {
	Seq      uint64
	LastHash string
}

ChainHead is a trusted tail anchor for a publisher's chain: the sequence and digest of the last entry the publisher is known to have signed. Recorded externally (e.g. AgentSession.status) so truncation of the stored tail is detectable.

type Finding

type Finding struct {
	Verdict Verdict
	Kind    string
	ID      string
	Seq     uint64
	Detail  string
}

Finding is one verifier verdict against a specific entry (or, for tail truncation, against the chain head). Seq is the offending entry's sequence (or the expected anchor seq for truncation).

type MapKeyLookup

type MapKeyLookup map[PubKeyRef]ed25519.PublicKey

MapKeyLookup is a static (publisher,keyID)→key PublisherKeyLookup built from a trusted snapshot (used by oap audit verify and tests). Keying by BOTH publisher and keyID — not keyID alone — is what stops a key registered for one publisher from verifying another publisher's chain, matching WriteVerifier's write-time semantics.

func (MapKeyLookup) PublisherKey

func (m MapKeyLookup) PublisherKey(publisher, keyID string) (ed25519.PublicKey, bool)

PublisherKey resolves a (publisher, keyID) pair. Satisfies PublisherKeyLookup.

type PubKeyRef

type PubKeyRef struct {
	Publisher string
	KeyID     string
}

PubKeyRef identifies a trusted key by the (publisher, keyID) pair — the same composite the write-time PublisherKeyLookup keys on.

type PublisherKeyLookup

type PublisherKeyLookup interface {
	PublisherKey(publisher, keyID string) (ed25519.PublicKey, bool)
}

PublisherKeyLookup resolves a publisher's registered public keys for verify-on-write. The operator composes the per-session key registry (tokens) and the component key registry behind this.

type Report

type Report struct {
	Publisher string
	OK        int
	Findings  []Finding
}

Report is the outcome of verifying one publisher's chain. OK counts the entries that passed every check; Findings lists every problem.

type Signer

type Signer struct {
	// contains filtered or unexported fields
}

Signer attests append-only entries for one publisher with one Ed25519 key. It maintains per-scope hash-chain state under a mutex so concurrent Puts across distinct scopes don't interleave, and a single scope's chain advances atomically. A Signer is safe for concurrent use.

func NewSigner

func NewSigner(priv ed25519.PrivateKey, publisher string) *Signer

NewSigner builds a Signer for publisher over priv. The key ID is derived from the public half via KeyID.

func (*Signer) EnsureSeeded

func (s *Signer) EnsureSeeded(ctx context.Context, mem memory.Memory, scope memory.Scope) error

EnsureSeeded runs SeedFromMemory at most once per scope. Subsequent calls for the same scope are no-ops, so the hot Put path pays the query cost only on the first append-only write into a scope.

func (*Signer) InvalidateSeed

func (s *Signer) InvalidateSeed(scope memory.Scope)

InvalidateSeed forces the next EnsureSeeded(scope) to re-derive the chain head from durable storage. Call it whenever a signed entry did NOT land at the position Sign minted for it — the store rejected the write, or accepted it without persisting that position (an append-only re-put whose payload matches a stored entry is idempotent: the facade returns the stored entry, discarding the freshly-minted seq). Sign advances the in-memory head before the Put, so either case otherwise strands the head ahead of the durable tail, and the next accepted append would skip a seq (gap) and link its prevHash to a digest that was never stored (fork). Re-seeding rebuilds the head to the highest seq that actually landed — the right answer whether the lost entry was the chain tail or an older position being re-put.

func (*Signer) KeyID

func (s *Signer) KeyID() string

KeyID returns the hex key identifier of this Signer's public key.

func (*Signer) Publisher

func (s *Signer) Publisher() string

Publisher returns the publisher identity this Signer attests as.

func (*Signer) SeedFromMemory

func (s *Signer) SeedFromMemory(ctx context.Context, mem memory.Memory, scope memory.Scope) error

SeedFromMemory rebuilds this Signer's chain head for scope from persisted entries, so a freshly constructed Signer (after a process restart or session resume) continues the chain rather than restarting it at Seq 1. It scans every registered append-only Kind for entries this publisher signed and adopts the one with the highest Seq as the new chain head.

func (*Signer) Sign

func (s *Signer) Sign(e *memory.Entry) error

Sign fills e.Provenance with this Signer's publisher/keyID, the next sequence number for e.Scope, and the prior entry's digest as PrevHash, then signs the canonical digest and advances the chain. The signature covers the digest, which already includes the provenance header (publisher/keyID/seq/prevHash) — so it commits to chain position as well as payload.

type SigningMemory

type SigningMemory struct {
	memory.Memory
	// contains filtered or unexported fields
}

SigningMemory decorates a memory.Memory so that Put on an append-only Kind seeds the publisher's chain (once per scope) and signs the entry before it reaches the inner store. Query, Search, and SendSignal pass through unchanged via the embedded Memory.

Every append-only writer in the process (authz-decision recording, approval requests, audit-log appends, lifecycle events, transcript writes — on a detached goroutine or the live turn) goes through one SigningMemory per session, so serializing Sign+Put here covers all of them from a single choke point; see Put.

func NewSigningMemory

func NewSigningMemory(inner memory.Memory, signer *Signer) *SigningMemory

NewSigningMemory wraps inner so append-only Puts are attested by signer.

func (*SigningMemory) Put

Put signs append-only entries before forwarding them; mutable-Kind entries pass through untouched (Provenance stays nil).

func (*SigningMemory) PutToPool

func (m *SigningMemory) PutToPool(ctx context.Context, sessionScope, poolScope memory.Scope, e memory.Entry) (memory.Entry, error)

PutToPool signs an append-only entry before forwarding it into a resource pool, the same way Put signs one before forwarding it to the caller's own scope — see Put's own comments for why Sign and the durable write must be atomic per scope, why a rejected write must not strand the in-memory head, and why a nil error is not proof the signed position landed. This method mirrors that discipline against poolScope rather than e.Scope, because the entry lands at the pool, not at sessionScope.

FAILS LOUDLY — a distinct, named error identifying the concrete inner type — when m.Memory does not implement memory.PoolWriter, rather than silently declining the write. SigningMemory embeds Memory as an INTERFACE field, so Go's method promotion never forwards a concrete inner's extra methods on its own; an operator reading this error learns exactly which concrete type behind the signing wrapper needs to grow the capability, instead of chasing a permission-shaped refusal that was never about permissions.

e.Scope is stamped to poolScope before signing (overwriting whatever the caller set, or left zero) because EntryDigest signs e.Scope, and the server forces the STORED entry's scope to be exactly the pool the request line named — signing anything else would produce a signature that could never verify against what actually lands.

type Verdict

type Verdict string

Verdict labels one thing the verifier found wrong (or, by absence, right) about a chain entry. A defined type (not a string alias) so the compiler type-checks verdict values at use sites.

const (
	// VerdictUnsigned: an append-only entry carries no Provenance.
	VerdictUnsigned Verdict = "unsigned"
	// VerdictUnknownKey: the entry's KeyID is not in the trusted set.
	VerdictUnknownKey Verdict = "unknown-key"
	// VerdictBadSignature: the Ed25519 signature does not verify over
	// the recomputed digest.
	VerdictBadSignature Verdict = "bad-signature"
	// VerdictGap: a sequence number is missing from the chain.
	VerdictGap Verdict = "gap"
	// VerdictFork: two entries share a Seq, or a PrevHash does not link
	// to the prior entry's digest.
	VerdictFork Verdict = "fork"
	// VerdictTailTruncated: a trusted anchor expects a higher max Seq
	// (or a different head hash) than the stored chain provides.
	VerdictTailTruncated Verdict = "tail-truncated"
)

type Verifier

type Verifier struct {
	// contains filtered or unexported fields
}

Verifier checks chains against a trusted key set. It resolves keys by (publisher, keyID) — the same composite the write-time WriteVerifier uses — so a key registered for one publisher cannot verify a chain attributed to another.

func NewVerifier

func NewVerifier(keys PublisherKeyLookup) *Verifier

NewVerifier builds a Verifier over the given (publisher,keyID) lookup. Pass a MapKeyLookup, a *publisherkeys.Registry, or any PublisherKeyLookup.

func (*Verifier) VerifyChain

func (v *Verifier) VerifyChain(publisher string, entries []memory.Entry, anchor *ChainHead) Report

VerifyChain walks publisher's chain within entries and reports every problem found. Entries signed by other publishers are ignored. Append-only entries carrying no Provenance are reported unsigned. The remaining entries are sorted by Seq and checked for forks (duplicate Seq, broken prevHash linkage), gaps (missing Seq from 1), unknown keys, and bad signatures. When anchor is non-nil, a stored max Seq below the anchor — or an equal Seq whose head digest differs — is reported as tail truncation.

type WriteVerifier

type WriteVerifier struct {
	// contains filtered or unexported fields
}

WriteVerifier implements memory.ProvenanceVerifier: it requires a provenance envelope on append-only writes and verifies the signature under a registered key for the entry's publisher.

func NewWriteVerifier

func NewWriteVerifier(keys PublisherKeyLookup) *WriteVerifier

NewWriteVerifier builds a verify-on-write enforcer over keys.

func (*WriteVerifier) VerifyEntry

func (w *WriteVerifier) VerifyEntry(ctx context.Context, caller string, e memory.Entry) error

VerifyEntry rejects an append-only write that lacks provenance, whose publisher is not one the authenticated writer may author as, whose key is not registered, or whose signature does not verify over the entry digest.

The publisher check (checkAuthor) and the key/signature check are two different guarantees and BOTH are load-bearing. The signature proves the entry was produced by whoever holds the publisher's key; the publisher check proves the writer on this connection IS that publisher. Drop the second and forged attribution is refused only for as long as no key ever leaks or is mis-registered — precisely the containment an audit log exists to provide.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL