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:
- 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.
- 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 ¶
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.
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 )
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.
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 ¶
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 ¶
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.