envelope

package
v0.2.3 Latest Latest
Warning

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

Go to latest
Published: Jul 28, 2026 License: MIT Imports: 10 Imported by: 0

README

Contract B — Message-Envelope Signing (Slice 5)

This package lets an agent sign an outgoing chat-message envelope so a receiving daemon (workhorse) can verify the signature + the signer's registry binding and then enforce policy. It builds strictly on slices 1–4 and reimplements no canonicalization, signing, or verification.

This is the contract workhorse's chat-mcp + daemon (and its Rust verifier) bind to. The deterministic acceptance fixture is testdata/message_signing_vectors.json, kept lockstep with the generator by TestMessageSigningFixture_MatchesGenerator (mirroring slice 4's cross_language_vectors.json).

The signed subset

The signature covers the JCS (RFC 8785) canonical form of exactly these fields:

JCS({ alg_version, body, from_agent, key_epoch, nonce, room?, seq, to_agent?, ts })

where exactly one of room | to_agent is present and the other is OMITTED — not null. In JCS an absent key and a null value produce different bytes, so this distinction is load-bearing across languages.

from_pubkey is DERIVED, NOT SIGNED: it is never one of the signed bytes. The verifier resolves the signing pubkey from the registry via (from_agent, key_epoch). A stamped from_pubkey is a convenience hint only and is never trusted as the verification key.

Excluded from the signed bytes (transport / receiver-stamped metadata): id, sig, from_pubkey, receive_ts, ioguard_verdict, origin_daemon.

Encodings (pinned for cross-language parity)

sig, from_pubkey, and the registry's pubkey / root_sig are base64 standard alphabet with padding (RFC 4648 §4 — +//, = padding; not URL-safe, not unpadded). The Rust side MUST decode with the standard, padded alphabet or signatures and keys fail to round-trip.

Gates (enforced pre-sign, re-surfaced at verify)

All are typed rejects — values are never silently coerced or normalized:

Field Rule
alg_version must be exactly 1 (anti-downgrade pin)
alg_version, seq, ts integers in [0, 2^53]
key_epoch integer in [1, 2^53] (same anti-rollback floor as the registry)
body valid UTF-8 and Unicode NFC (never silently normalized)
nonce non-empty ASCII (base64 of ≥16 random bytes recommended)
from_agent non-empty (it is the registry resolve key)
room/to_agent exactly one present (both → reject, neither → reject)

Out-of-range integers are rejected, never silently rounded — JCS renders numbers as IEEE-754 doubles, so an epoch/seq/ts above 2^53 would otherwise lose precision and break cross-language parity.

Verify order

VerifyEnvelope(reg, fields, sig, now):

  1. CanonicalizeEnvelope(fields) — re-runs every gate over the received fields (anti-downgrade, ranges, body NFC, exactly-one routing) and rebuilds the canonical signed bytes. A gate failure is a typed reject.
  2. registry.VerifyMessage(reg, from_agent, key_epoch, canonical, sig, now) — resolves the live binding for (from_agent, key_epoch), default-denies a revoked / expired / not-yet-valid / epoch-mismatched binding, then ZIP-215 strict-verifies sig over the canonical bytes under the binding's validated (small-order-rejected) pubkey.

A gate failure, malformed signature, or registry default-deny returns (false, non-nil error); an honest signature non-match returns (false, nil); a valid envelope returns (true, nil).

Anti-replay is the DAEMON's job — not here

VerifyEnvelope authenticates the signature + the registry binding only. It is stateless. Anti-replay — the per-from_agent seq high-water mark and the nonce-unseen set — is the receiving daemon's stateful responsibility (workhorse). A replayed but otherwise-valid envelope verifies here and must be rejected by the daemon's replay layer. The fixture therefore carries no "replayed" reject case.

CLI

vaultmind identity sign-envelope [--file env.json] [--signer-socket PATH] [--from-pubkey B64]

Reads the signed-subset envelope JSON from stdin (or --file), enforces the gates, canonicalizes, signs the canonical bytes via the keyless signer over its 0600 socket, and prints {sig, from_pubkey, key_epoch} as JSON. The CLI never opens the private-key file; if the signer is unreachable it fails closed (an error, never a silent unsigned result). --from-pubkey only populates the from_pubkey hint (it is not signed).

Fixture cases

testdata/message_signing_vectors.json carries a pinned root pubkey, a signed registry with a mira binding at key_epoch=1, one valid signed envelope (accepted), and reject cases that must all fail: tampered_body, wrong_key_epoch, downgraded_alg_version, key_epoch_above_2pow53, seq_above_2pow53, non_nfc_body, both_room_and_to_agent, neither_room_nor_to_agent, tampered_sig, wrong_signer_key. (Replay is daemon-scope and intentionally absent.)

Regenerate with:

go test ./internal/identity/envelope/ -run TestGenerate_MessageSigningFixture -update

Documentation

Overview

Package envelope implements Contract-B SLICE 5: signing and verifying a chat MESSAGE envelope. It lets an agent SIGN an outgoing chat-message envelope so a receiving daemon (workhorse) can VERIFY the signature + the signer's registry binding and then enforce policy.

It builds STRICTLY on slices 1-4 and reimplements nothing:

  • slice 1 (internal/identity): identity.Canonicalize (RFC 8785 JCS) for the canonical signed bytes; identity.ValidateSchema for the shared integer-range / UTF-8 / NFC gate; identity.SignCanonical / VerifyCanonical for the low-level ed25519 (small-order-rejecting, cofactorless strict verify) primitives.
  • slice 2 (internal/identity/signer): the KEYLESS UDS custody signer — the CLI signs via the signer process over its 0600 socket and NEVER opens the private-key file. SignEnvelope takes a SignerClient seam, not a key.
  • slice 3 (internal/identity/registry): registry.VerifyMessage resolves the signer's binding by (from_agent slug, key_epoch) -> validated pubkey, then VerifyCanonical. VerifyEnvelope delegates the binding+signature check to it.
  • slice 4: the cross_language_vectors.json fixture+generator pattern, mirrored here as testdata/message_signing_vectors.json so workhorse's Rust verifier binds to the SAME byte-for-byte acceptance contract.

The FROZEN signing contract (agreed with workhorse — do NOT deviate)

The signature covers the JCS canonical form of the SIGNED SUBSET:

JCS({ alg_version, body, from_agent, key_epoch, nonce, room?, seq, to_agent?, ts })

where EXACTLY ONE of room | to_agent is present and the other is OMITTED (not null — in JCS an absent key and a null value are different bytes, so this is load-bearing). The signed-subset field names are the Field*/SignedSubset SSOT constants in envelope.go.

Pre-sign gates (enforced by CanonicalizeEnvelope, surfaced again at verify):

  • alg_version, key_epoch, seq, ts are integers in [0, 2^53]; key_epoch is additionally in [1, 2^53] (same epoch rule as the registry). Out-of-range is a TYPED reject — never a silent IEEE-754 round (JCS renders numbers as doubles).
  • alg_version MUST be exactly AlgVersion (1) — an anti-downgrade pin.
  • body MUST be valid UTF-8 and Unicode NFC; a non-NFC / invalid-UTF8 body is rejected, never silently normalized.
  • nonce MUST be ASCII (base64 of >= 16 random bytes recommended).
  • EXACTLY ONE of room | to_agent present (both -> reject, neither -> reject).

from_pubkey is DERIVED, NOT SIGNED: it is NOT one of the signed bytes. The verifier resolves the signing pubkey from the registry via (from_agent, key_epoch); a stamped from_pubkey is a convenience/hint only and is never trusted as the verification key.

EXCLUDED from the signed bytes (transport / receiver-stamped metadata): id, sig, from_pubkey, receive_ts, ioguard_verdict, origin_daemon.

Verify order

VerifyEnvelope:

  1. CanonicalizeEnvelope(fields) — re-run EVERY pre-sign gate over the received fields (anti-downgrade, ranges, body NFC, exactly-one routing) and rebuild the canonical signed bytes. A gate failure is a typed reject.
  2. registry.VerifyMessage(reg, from_agent, key_epoch, canonical, sig, now) — resolve the live binding for (from_agent, key_epoch), default-deny a revoked/expired/not-yet-valid/epoch-mismatched binding, then cofactorless-strict-verify sig over the canonical bytes under the binding's validated pubkey.

Boundary: anti-replay is the DAEMON's job, NOT here

VerifyEnvelope authenticates the SIGNATURE + the registry BINDING only. It is STATELESS. Anti-replay — the per-(from_agent) seq high-water mark and the nonce-unseen set — is the receiving daemon's stateful responsibility (workhorse). This package deliberately keeps no replay state; a replayed but otherwise-valid envelope verifies here and MUST be rejected by the daemon's replay layer. The fixture therefore carries no "replayed" reject case.

Index

Constants

View Source
const (
	FieldAlgVersion = "alg_version"
	FieldBody       = "body"
	FieldFromAgent  = "from_agent"
	FieldKeyEpoch   = "key_epoch"
	FieldNonce      = "nonce"
	FieldRoom       = "room"
	FieldSeq        = "seq"
	FieldToAgent    = "to_agent"
	FieldTS         = "ts"

	// FieldFromClass, FieldKind, FieldVouchesFor, FieldGateRef are the OPTIONAL
	// human-principal signed fields (S3). Each is OMITTED when absent (absent !=
	// null in JCS), exactly like room/to_agent — never emitted as a null value.
	FieldFromClass  = "from_class"
	FieldKind       = "kind"
	FieldVouchesFor = "vouches_for"
	FieldGateRef    = "gate_ref"

	// FieldSig and FieldFromPubKey are TRANSPORT field names (NOT in the signed
	// subset). They are the keys of the {sig, from_pubkey, key_epoch} result the
	// caller stamps into the wire envelope after signing.
	FieldSig        = "sig"
	FieldFromPubKey = "from_pubkey"
)

Signed-subset field names (SSOT). These are the EXACT keys that enter the JCS-canonical signed bytes. JCS sorts keys, so the order they appear here is for readability only — the canonical order is Canonicalize's output.

View Source
const (
	// AlgVersion is the pinned signing algorithm version. CanonicalizeEnvelope
	// rejects any other value (anti-downgrade).
	AlgVersion = 1

	// MaxSafeInt is the inclusive upper bound for the signed integer fields
	// (alg_version, key_epoch, seq, ts). It is 2^53 — the largest integer JCS
	// (which renders numbers as IEEE-754 doubles) round-trips without precision
	// loss. Mirrors registry.MaxSafeEpoch / identity's maxSafeInteger.
	MaxSafeInt = int64(1) << 53
	// MinKeyEpoch is the inclusive lower bound for key_epoch (same anti-rollback
	// floor as the registry: a zero/negative epoch is rejected).
	MinKeyEpoch = 1
)
View Source
const (
	// FromClassAgent | FromClassBridge | FromClassHuman are the valid from_class
	// values. FromClassAgent is also the registry's default effective class
	// (registry.ClassAgent).
	FromClassAgent  = "agent"
	FromClassBridge = "bridge"
	FromClassHuman  = "human"

	// KindChat | KindApproval are the valid kind values. An absent kind is treated
	// as chat at verify (no effect), but a PRESENT kind must be one of these.
	KindChat     = "chat"
	KindApproval = "approval"
)

Human-principal enum values (SSOT). from_class and kind, when present, must be EXACTLY one of these — any other value is a typed wrap-side reject.

View Source
const (
	// ErrAlgVersion is returned when alg_version != AlgVersion (anti-downgrade).
	ErrAlgVersion = "envelope: alg_version must be 1 (anti-downgrade)"
	// ErrIntRange is returned when alg_version, seq, or ts is outside [0, 2^53].
	ErrIntRange = "envelope: integer field out of range [0, 2^53]"
	// ErrKeyEpochRange is returned when key_epoch is outside [1, 2^53].
	ErrKeyEpochRange = "envelope: key_epoch out of range [1, 2^53]"
	// ErrBodyUTF8 is returned when body is not valid UTF-8.
	ErrBodyUTF8 = "envelope: body is not valid UTF-8"
	// ErrBodyNotNFC is returned when body is not Unicode NFC (no silent normalize).
	ErrBodyNotNFC = "envelope: body must be Unicode NFC-normalized"
	// ErrNonceASCII is returned when nonce is not ASCII.
	ErrNonceASCII = "envelope: nonce must be ASCII"
	// ErrNonceEmpty is returned when nonce is empty.
	ErrNonceEmpty = "envelope: nonce must not be empty"
	// ErrFromAgentEmpty is returned when from_agent is empty.
	ErrFromAgentEmpty = "envelope: from_agent must not be empty"
	// ErrRoutingExactlyOne is returned when NOT exactly one of room|to_agent is
	// present (both set, or neither set). Absent != null is load-bearing in JCS.
	ErrRoutingExactlyOne = "envelope: exactly one of room or to_agent must be present"

	// ErrFieldNotUTF8 is returned when a present human-principal string field
	// (from_class, kind, vouches_for, gate_ref) is not valid UTF-8.
	ErrFieldNotUTF8 = "envelope: signed string field is not valid UTF-8"
	// ErrFieldNotNFC is returned when a present human-principal string field is
	// not Unicode NFC (no silent normalize — same discipline as body).
	ErrFieldNotNFC = "envelope: signed string field must be Unicode NFC-normalized"
	// ErrFromClassInvalid is returned when a present from_class is not one of
	// agent|bridge|human.
	ErrFromClassInvalid = "envelope: from_class must be agent, bridge, or human"
	// ErrKindInvalid is returned when a present kind is not one of chat|approval.
	ErrKindInvalid = "envelope: kind must be chat or approval"
	// ErrBridgeNeedsVouch is returned when from_class=bridge but vouches_for is
	// absent (a bridge MUST name whom it vouches for).
	ErrBridgeNeedsVouch = "envelope: from_class=bridge requires vouches_for"
	// ErrVouchNeedsBridge is returned when vouches_for is present but from_class
	// is not bridge (only a bridge may vouch).
	ErrVouchNeedsBridge = "envelope: vouches_for requires from_class=bridge"
	// ErrApprovalNeedsGateRef is returned when kind=approval but gate_ref is
	// absent (an approval MUST reference the gate it approves).
	ErrApprovalNeedsGateRef = "envelope: kind=approval requires gate_ref"
	// ErrGateRefNeedsApproval is returned when gate_ref is present but kind is not
	// approval (gate_ref is only meaningful on an approval).
	ErrGateRefNeedsApproval = "envelope: gate_ref requires kind=approval"

	// ErrVerifyClassMismatch is returned by VerifyEnvelope when an envelope's
	// PRESENT from_class claim does not equal the resolved binding's effective
	// class. The signature authenticates the claim; only the registry binding
	// AUTHORIZES it (authenticated != authorized — fail closed).
	ErrVerifyClassMismatch = "envelope: from_class claim does not match the registry binding class"
	// ErrVerifyVouchNotAllowed is returned by VerifyEnvelope when a bridge's
	// vouches_for is not in the binding's VouchAllowlist (empty allowlist ⇒ every
	// vouch rejected).
	ErrVerifyVouchNotAllowed = "envelope: vouches_for is not in the binding's vouch allowlist"
)

Gate reject messages (SSOT — referenced from the gate path and asserted by tests/callers, never inlined).

Variables

This section is empty.

Functions

func CanonicalizeEnvelope

func CanonicalizeEnvelope(fields Fields) (identity.CanonicalBytes, error)

CanonicalizeEnvelope enforces EVERY pre-sign gate over fields and returns the JCS-canonical bytes of the signed subset. It is the single SSOT for both the sign path and the verify path, so the bytes signed are byte-identical to the bytes verified. It returns a typed error (never silently coerces) on any gate failure. from_pubkey and all transport fields are NEVER included.

func VerifyEnvelope

func VerifyEnvelope(reg registry.Registry, fields Fields, sig []byte, now time.Time) (bool, error)

VerifyEnvelope re-runs every pre-sign gate over the received fields, rebuilds the canonical signed bytes, then delegates the binding+signature check to registry.VerifyMessage (resolve the live binding for (from_agent, key_epoch) -> validated pubkey -> cofactorless strict verify). It authenticates the SIGNATURE + the registry BINDING only — anti-replay (seq high-water + nonce-unseen) is the daemon's stateful job and is NOT performed here.

It distinguishes failure kinds the same way the lower layers do: a gate failure, a malformed signature, or a registry default-deny (unknown / revoked / expired / not-yet-valid / epoch-mismatch / small-order key) returns (false, non-nil error); an honest signature non-match returns (false, nil); a valid envelope returns (true, nil). It never panics.

Types

type Fields

type Fields struct {
	// AlgVersion, KeyEpoch, Seq, TS are int64 (NOT int) ON PURPOSE: the signed
	// numerics are JCS/IEEE-754 bounded to [0, 2^53], and that gate must be the
	// single, platform-independent authority. With a plain int these would parse
	// differently on a 32-bit build (a valid >2^31 value would hit a JSON parse
	// error instead of the typed range gate), breaking Go<->Rust parity — the same
	// class of bug as the slice-3 epoch precision hole.
	AlgVersion int64
	Body       string
	FromAgent  string
	KeyEpoch   int64
	Nonce      string
	// Room and ToAgent: exactly one non-nil. The signed subset emits whichever is
	// present and OMITS the other.
	Room    *string
	ToAgent *string
	Seq     int64
	TS      int64
	// FromClass, Kind, VouchesFor, GateRef are the OPTIONAL human-principal signed
	// fields. Each is a POINTER so absent (nil) is OMITTED from the signed bytes
	// (never emitted as null), exactly like Room/ToAgent. When present they enter
	// the JCS map by lexicographic key name and are gated by checkGates.
	FromClass  *string
	Kind       *string
	VouchesFor *string
	GateRef    *string
}

Fields is the message-envelope input. Room and ToAgent are POINTERS so the caller can express absent-vs-empty: EXACTLY ONE must be non-nil (the other nil is OMITTED from the signed bytes, never emitted as null). The transport-only fields (id, sig, from_pubkey, receive_ts, ioguard_verdict, origin_daemon) are deliberately NOT modeled here — they are excluded from the signed subset.

type SignResult

type SignResult struct {
	// Sig is base64-std of the ed25519 signature over the canonical signed bytes.
	Sig string
	// FromPubKey is base64-std of the signer's ed25519 public key (hint only).
	FromPubKey string
	// KeyEpoch echoes the signed key_epoch the caller stamps alongside sig.
	KeyEpoch int64
}

SignResult is what SignEnvelope returns: the base64 fields the caller stamps into the wire envelope. from_pubkey is a convenience hint (DERIVED, NOT part of the signed bytes); the verifier resolves the real key from the registry.

func SignEnvelope

func SignEnvelope(client SignerClient, fields Fields, fromPubKey ed25519.PublicKey) (SignResult, error)

SignEnvelope gates+canonicalizes fields, then signs the canonical bytes via the KEYLESS signer client and returns the {sig, from_pubkey, key_epoch} the caller stamps into the wire envelope. fromPubKey is the signer's public key, used ONLY to populate the convenience hint — it is NOT part of the signed bytes and the verifier ignores it (resolving the real key from the registry). It FAILS CLOSED: any gate, canonicalize, or signer error returns a non-nil error.

type SignerClient

type SignerClient interface {
	Sign(canonicalBytes []byte) ([]byte, error)
}

SignerClient is the minimal KEYLESS seam SignEnvelope needs: hand it canonical bytes, get a signature back. The real implementation is *signer.Client; the CLI never opens the key file.

Jump to

Keyboard shortcuts

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