diff --git a/cmd/busvectors/main.go b/cmd/busvectors/main.go new file mode 100644 index 00000000..ef34226e --- /dev/null +++ b/cmd/busvectors/main.go @@ -0,0 +1,229 @@ +// Command busvectors emits deterministic cross-language test vectors for the bus +// protocol and its end-to-end crypto. The browser-native client (uniweb) ports the +// protocol to TypeScript; these vectors are the contract that proves the port is +// byte-for-byte compatible with this Go reference implementation (issue +// uniweb/0001, Phase 0). +// +// Every input is fixed (hardcoded key material and messages) so the output is +// stable across runs and can be committed as a golden file. The crypto primitives +// are the SAME registry functions the bus uses (functions/cybersecurity), so the +// vectors exercise the real path, not a test-only reimplementation. +// +// Coverage: +// - endpoint_id : EndpointID(signPub) = base64url(sha256(signPub)) +// - sign : Ed25519 signature over a fixed message (deterministic) +// - aead : ChaCha20-Poly1305 seal with a FIXED nonce (deterministic, so +// the TS port must reproduce the same ciphertext AND open it) +// - keybox : sealed-box (X25519) of a room key for a recipient; the TS port +// must OPEN it (the ephemeral sender key is random, so only the +// open direction is a stable vector — the TS->Go seal direction +// is covered by the live E2E test in Phase 3) +// - frame : canonical JSON wire bytes of a Frame, and its SigningBytes +// +// Usage: +// +// go run ./cmd/busvectors > ../uniweb/web/src/bus/testdata/vectors.json +package main + +import ( + "crypto/ed25519" + "encoding/base64" + "encoding/hex" + "encoding/json" + "fmt" + "os" + + cs "fn-registry/functions/cybersecurity" + + "github.com/enmanuel/unibus/pkg/frame" + "golang.org/x/crypto/chacha20poly1305" + "golang.org/x/crypto/curve25519" +) + +// Fixed key material. The bytes are arbitrary but stable: the point is a golden +// file, not secrecy (these are test vectors, never real identities). +var ( + signSeed = mustHex("000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f") + kexPriv = mustHex("202122232425262728292a2b2c2d2e2f303132333435363738393a3b3c3d3e3f") + recipientKexPriv = mustHex("404142434445464748494a4b4c4d4e4f505152535455565758595a5b5c5d5e5f") + aeadKey = mustHex("606162636465666768696a6b6c6d6e6f707172737475767778797a7b7c7d7e7f") + aeadNonce = mustHex("808182838485868788898a8b") // 12 bytes (ChaCha20-Poly1305 IETF) + roomKey = mustHex("a0a1a2a3a4a5a6a7a8a9aaabacadaeafb0b1b2b3b4b5b6b7b8b9babbbcbdbebf") + signMessage = []byte("unibus parity vector message") + aeadAAD = []byte("unibus-room-42") + aeadPlaintext = []byte("hello from the bus") +) + +// vectors is the JSON document consumed by the TypeScript parity tests. Every field +// is hex except the frame wire bytes, which are base64 (the frame is JSON, so the +// TS side compares the exact UTF-8 bytes). +type vectors struct { + Note string `json:"note"` + Endpoint endpointVector `json:"endpoint_id"` + Sign signVector `json:"sign"` + AEAD aeadVector `json:"aead"` + KeyBox keyboxVector `json:"keybox"` + Frame frameVector `json:"frame"` +} + +type endpointVector struct { + SignPubHex string `json:"sign_pub_hex"` + EndpointID string `json:"endpoint_id"` // base64url(sha256(sign_pub)), unpadded +} + +type signVector struct { + SignPrivHex string `json:"sign_priv_hex"` + SignPubHex string `json:"sign_pub_hex"` + MessageHex string `json:"message_hex"` + SigHex string `json:"sig_hex"` +} + +type aeadVector struct { + KeyHex string `json:"key_hex"` + NonceHex string `json:"nonce_hex"` + AADHex string `json:"aad_hex"` + PlaintextHex string `json:"plaintext_hex"` + CiphertextHex string `json:"ciphertext_hex"` // includes the 16-byte Poly1305 tag +} + +type keyboxVector struct { + RecipientKexPubHex string `json:"recipient_kex_pub_hex"` + RecipientKexPrivHex string `json:"recipient_kex_priv_hex"` + SecretHex string `json:"secret_hex"` + SealedHex string `json:"sealed_hex"` +} + +type frameVector struct { + // The source fields, so the TS side can build the same Frame and compare. + Type int `json:"type"` + Subject string `json:"subject"` + Sender string `json:"sender"` + MsgID string `json:"msg_id"` + Epoch int `json:"epoch"` + NonceHex string `json:"nonce_hex"` + PayloadHex string `json:"payload_hex"` + WireB64 string `json:"wire_b64"` // base64(Marshal()) — full frame incl. sig + SigningB64 string `json:"signing_bytes_b64"` // base64(SigningBytes()) — what gets signed + SigHex string `json:"sig_hex"` // Ed25519 over SigningBytes +} + +func main() { + if err := run(os.Stdout); err != nil { + fmt.Fprintln(os.Stderr, "busvectors:", err) + os.Exit(1) + } +} + +func run(out *os.File) error { + // Identity from the fixed seed: Go's ed25519 private key layout is seed||pub, the + // same 64-byte layout cs.Identity and the TS wallet use. + signPriv := ed25519.NewKeyFromSeed(signSeed) + signPub := signPriv.Public().(ed25519.PublicKey) + + // X25519 public keys from the fixed private scalars (curve25519 clamps internally, + // matching @noble/curves x25519.getPublicKey). + kexPub, err := curve25519.X25519(kexPriv, curve25519.Basepoint) + if err != nil { + return fmt.Errorf("kex pub: %w", err) + } + recipientKexPub, err := curve25519.X25519(recipientKexPriv, curve25519.Basepoint) + if err != nil { + return fmt.Errorf("recipient kex pub: %w", err) + } + + // AEAD with a FIXED nonce so the vector is deterministic. This is the same cipher + // (ChaCha20-Poly1305 IETF, 12-byte nonce) that cs.SealAEAD uses; we set the nonce + // explicitly only to make the vector reproducible. OpenAEAD verifies round-trip. + aead, err := chacha20poly1305.New(aeadKey) + if err != nil { + return fmt.Errorf("aead cipher: %w", err) + } + ciphertext := aead.Seal(nil, aeadNonce, aeadPlaintext, aeadAAD) + if _, err := cs.OpenAEAD(aeadKey, aeadNonce, ciphertext, aeadAAD); err != nil { + return fmt.Errorf("aead self-check: %w", err) + } + + // Sealed box of the room key for the recipient. The sender's ephemeral key is + // random (anonymous sealed box), so SealedHex changes per run; the stable, useful + // assertion for the TS port is that OpenKeyBox recovers the secret, which we + // self-check here. The TS test opens SealedHex and compares to SecretHex. + sealed, err := cs.SealKeyBox(recipientKexPub, roomKey) + if err != nil { + return fmt.Errorf("seal keybox: %w", err) + } + if got, err := cs.OpenKeyBox(recipientKexPub, recipientKexPriv, sealed); err != nil || hex.EncodeToString(got) != hex.EncodeToString(roomKey) { + return fmt.Errorf("keybox self-check failed: %v", err) + } + + // A representative encrypted-room frame, signed end-to-end. + f := frame.Frame{ + Type: frame.PUB, + Subject: "room.parity", + Sender: frame.EndpointID(signPub), + MsgID: "01HZY0VECTORFIXEDULID0001", + Epoch: 1, + Nonce: aeadNonce, + Payload: ciphertext, + } + f.Sig = ed25519.Sign(signPriv, f.SigningBytes()) + wire, err := f.Marshal() + if err != nil { + return fmt.Errorf("marshal frame: %w", err) + } + + v := vectors{ + Note: "Deterministic cross-language vectors for the unibus protocol. Generated by " + + "cmd/busvectors in the unibus repo; regenerate with `go run ./cmd/busvectors`. " + + "sealed_hex varies per run (anonymous sealed box); assert via OpenKeyBox.", + Endpoint: endpointVector{ + SignPubHex: hex.EncodeToString(signPub), + EndpointID: frame.EndpointID(signPub), + }, + Sign: signVector{ + SignPrivHex: hex.EncodeToString(signPriv), + SignPubHex: hex.EncodeToString(signPub), + MessageHex: hex.EncodeToString(signMessage), + SigHex: hex.EncodeToString(ed25519.Sign(signPriv, signMessage)), + }, + AEAD: aeadVector{ + KeyHex: hex.EncodeToString(aeadKey), + NonceHex: hex.EncodeToString(aeadNonce), + AADHex: hex.EncodeToString(aeadAAD), + PlaintextHex: hex.EncodeToString(aeadPlaintext), + CiphertextHex: hex.EncodeToString(ciphertext), + }, + KeyBox: keyboxVector{ + RecipientKexPubHex: hex.EncodeToString(recipientKexPub), + RecipientKexPrivHex: hex.EncodeToString(recipientKexPriv), + SecretHex: hex.EncodeToString(roomKey), + SealedHex: hex.EncodeToString(sealed), + }, + Frame: frameVector{ + Type: int(f.Type), + Subject: f.Subject, + Sender: f.Sender, + MsgID: f.MsgID, + Epoch: f.Epoch, + NonceHex: hex.EncodeToString(f.Nonce), + PayloadHex: hex.EncodeToString(f.Payload), + WireB64: base64.StdEncoding.EncodeToString(wire), + SigningB64: base64.StdEncoding.EncodeToString(f.SigningBytes()), + SigHex: hex.EncodeToString(f.Sig), + }, + // kexPub is unused in a vector field today but derived above to validate the + // scalar; reference it so the intent is documented. + } + _ = kexPub + + enc := json.NewEncoder(out) + enc.SetIndent("", " ") + return enc.Encode(v) +} + +func mustHex(s string) []byte { + b, err := hex.DecodeString(s) + if err != nil { + panic("busvectors: bad fixed hex: " + s) + } + return b +}