0088fb946b
Extend the cross-language vectors with the NATS user nkey derived from the Ed25519 public key, and a signed control-plane request (CanonicalRequest + Ed25519 signature). These let the TypeScript busauth port verify it authenticates on both planes exactly like the Go client (issue uniweb/0001, Phase 1).
282 lines
11 KiB
Go
282 lines
11 KiB
Go
// 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/busauth"
|
|
"github.com/enmanuel/unibus/pkg/frame"
|
|
"github.com/enmanuel/unibus/pkg/membership"
|
|
"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"`
|
|
Nkey nkeyVector `json:"nkey"`
|
|
Sign signVector `json:"sign"`
|
|
AEAD aeadVector `json:"aead"`
|
|
KeyBox keyboxVector `json:"keybox"`
|
|
Frame frameVector `json:"frame"`
|
|
CtrlReq controlReqVector `json:"control_request"`
|
|
}
|
|
|
|
type endpointVector struct {
|
|
SignPubHex string `json:"sign_pub_hex"`
|
|
EndpointID string `json:"endpoint_id"` // base64url(sha256(sign_pub)), unpadded
|
|
}
|
|
|
|
type nkeyVector struct {
|
|
SignPubHex string `json:"sign_pub_hex"`
|
|
NkeyPublic string `json:"nkey_public"` // NATS user nkey ("U...") from the Ed25519 pubkey
|
|
}
|
|
|
|
type controlReqVector struct {
|
|
Method string `json:"method"`
|
|
Path string `json:"path"`
|
|
Ts string `json:"ts"`
|
|
Nonce string `json:"nonce"`
|
|
BodyHex string `json:"body_hex"` // raw request body (empty for GET)
|
|
CanonicalHex string `json:"canonical_hex"` // bytes that get signed
|
|
SigHex string `json:"sig_hex"` // Ed25519 over canonical, by the signer below
|
|
SignPrivHex string `json:"sign_priv_hex"`
|
|
}
|
|
|
|
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)
|
|
}
|
|
|
|
// NATS user nkey derived from the Ed25519 public key (the browser must produce
|
|
// the same "U..." string to authenticate on the data plane).
|
|
nkeyPub, err := busauth.NkeyPublicFromSignPub(signPub)
|
|
if err != nil {
|
|
return fmt.Errorf("nkey public: %w", err)
|
|
}
|
|
|
|
// A signed control-plane request vector: the browser signs CanonicalRequest the
|
|
// same way to authenticate every HTTP call to membershipd. A POST with a body
|
|
// exercises the sha256(body) term.
|
|
const ctrlMethod = "POST"
|
|
const ctrlPath = "/rooms"
|
|
const ctrlTs = "1700000000"
|
|
const ctrlNonce = "Zm9vYmFyMTIzNDU2Nzg5MA=="
|
|
ctrlBody := []byte(`{"subject":"room.parity"}`)
|
|
canonical := membership.CanonicalRequest(ctrlMethod, ctrlPath, ctrlTs, ctrlNonce, ctrlBody)
|
|
ctrlSig := ed25519.Sign(signPriv, canonical)
|
|
|
|
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),
|
|
},
|
|
Nkey: nkeyVector{
|
|
SignPubHex: hex.EncodeToString(signPub),
|
|
NkeyPublic: nkeyPub,
|
|
},
|
|
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),
|
|
},
|
|
CtrlReq: controlReqVector{
|
|
Method: ctrlMethod,
|
|
Path: ctrlPath,
|
|
Ts: ctrlTs,
|
|
Nonce: ctrlNonce,
|
|
BodyHex: hex.EncodeToString(ctrlBody),
|
|
CanonicalHex: hex.EncodeToString(canonical),
|
|
SigHex: hex.EncodeToString(ctrlSig),
|
|
SignPrivHex: hex.EncodeToString(signPriv),
|
|
},
|
|
// 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
|
|
}
|