Help us shape the future of Go! Take the 2026 Go Developer Survey

envelope

package
v0.9.13 Latest Latest
Warning

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

Go to latest
Published: Sep 26, 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