server: internal/e2ee — Go port of crypto.js, pinned by JS-generated vectors (#262)
First slice of the Go bot sidecar (docs/bots.md § Direction: a Go sidecar). Mirrors the client wire format exactly: AES-GCM iv||ct blobs (JSON + bytes), ECIES thread-key wrap / seal-open (ephPub(65)||iv||ct), invite derivation (HKDF domain-separated id/wrapKey/claimSecret), WebCrypto-compatible raw + pkcs8 keypairs. testdata/vectors.json is generated from web/static/crypto.js by test/gen-e2ee-vectors.mjs, so every fixture was produced by the code the browser runs — the deterministic invite vectors must match byte-for-byte and do.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
5 files changed,
+813
−0
docs/bots.md+7 −0internal/e2ee/e2ee.go+350 −0internal/e2ee/e2ee_test.go+242 −0internal/e2ee/testdata/vectors.json+127 −0test/gen-e2ee-vectors.mjs+87 −0
diff --git a/docs/bots.md b/docs/bots.md| index 47afa9d..2b08c02 100644 |
| --- a/docs/bots.md |
| +++ b/docs/bots.md |
| @@ -101,6 +101,13 @@ same fixture set. Incremental path: port the **Announcer** first (write-only, |
| no model API — exercises provision/connect/encrypt with the least surface), |
| then the shared runtime, then Eleven's persona layer. |
| +**Status: the crypto port is landed.** `internal/e2ee` mirrors `crypto.js` |
| +(AES-GCM blobs, ECIES wrap/seal/open, invite derivation, WebCrypto-compatible |
| +raw/pkcs8 keypairs), pinned by `internal/e2ee/testdata/vectors.json` — fixtures |
| +generated from `crypto.js` itself by `test/gen-e2ee-vectors.mjs` (regenerate |
| +them after any deliberate `crypto.js` change and re-run `go test |
| +./internal/e2ee`). Next slices: headless provisioning, then the Announcer. |
| + |
| ## What exists today (`agent/`) |
| A headless Node sidecar that behaves like a member's browser: |
diff --git a/internal/e2ee/e2ee.go b/internal/e2ee/e2ee.go| new file mode 100644 |
| index 0000000..248ebc2 |
| --- /dev/null |
| +++ b/internal/e2ee/e2ee.go |
| @@ -0,0 +1,350 @@ |
| +// Package e2ee is the Go mirror of web/static/crypto.js — the client-side |
| +// encryption every lchat member speaks. It exists so bots can be ordinary Go |
| +// processes instead of Node (docs/bots.md § Direction: a Go sidecar). |
| +// |
| +// The wire format is crypto.js's, byte for byte: |
| +// |
| +// - AES-256-GCM blobs: base64url( iv(12) || ciphertext ) — or the raw bytes |
| +// for attachments. |
| +// - ECIES to a pubkey: base64url( ephPub(65) || iv(12) || ciphertext ), |
| +// key = HKDF-SHA256(ECDH(eph, recipient), salt="", info="lchat-thread-wrap"). |
| +// - Invites: id / wrapKey / claimSecret each HKDF-derived from the |
| +// link-fragment secret T with domain-separated info strings. |
| +// |
| +// Compatibility is pinned by testdata/vectors.json, generated from crypto.js |
| +// itself (test/gen-e2ee-vectors.mjs). Any change here or in crypto.js must keep |
| +// those vectors passing — they are the contract. |
| +package e2ee |
| + |
| +import ( |
| + "crypto/aes" |
| + "crypto/cipher" |
| + "crypto/ecdh" |
| + "crypto/ecdsa" |
| + "crypto/rand" |
| + "crypto/sha256" |
| + "crypto/x509" |
| + "encoding/base64" |
| + "encoding/hex" |
| + "encoding/json" |
| + "fmt" |
| + "io" |
| + |
| + "golang.org/x/crypto/hkdf" |
| +) |
| + |
| +// B64uEncode matches crypto.js bytesToB64url (unpadded base64url). |
| +func B64uEncode(b []byte) string { return base64.RawURLEncoding.EncodeToString(b) } |
| + |
| +// B64uDecode matches crypto.js b64urlToBytes; it tolerates padding. |
| +func B64uDecode(s string) ([]byte, error) { |
| + for len(s)%4 != 0 { |
| + s += "=" |
| + } |
| + return base64.URLEncoding.DecodeString(s) |
| +} |
| + |
| +// GenerateKey returns a fresh AES-256 key (raw bytes; B64uEncode to share it). |
| +func GenerateKey() ([]byte, error) { |
| + key := make([]byte, 32) |
| + _, err := rand.Read(key) |
| + return key, err |
| +} |
| + |
| +// ImportKey decodes a base64url AES key and checks its length. |
| +func ImportKey(b64 string) ([]byte, error) { |
| + key, err := B64uDecode(b64) |
| + if err != nil { |
| + return nil, err |
| + } |
| + if len(key) != 32 { |
| + return nil, fmt.Errorf("e2ee: AES key is %d bytes, want 32", len(key)) |
| + } |
| + return key, nil |
| +} |
| + |
| +func newGCM(key []byte) (cipher.AEAD, error) { |
| + block, err := aes.NewCipher(key) |
| + if err != nil { |
| + return nil, err |
| + } |
| + return cipher.NewGCM(block) |
| +} |
| + |
| +// EncryptBytes seals pt and returns raw iv(12)||ciphertext, like crypto.js |
| +// encryptBytes (attachments upload these bytes as octet-stream). |
| +func EncryptBytes(key, pt []byte) ([]byte, error) { |
| + gcm, err := newGCM(key) |
| + if err != nil { |
| + return nil, err |
| + } |
| + iv := make([]byte, 12) |
| + if _, err := rand.Read(iv); err != nil { |
| + return nil, err |
| + } |
| + return gcm.Seal(iv, iv, pt, nil), nil |
| +} |
| + |
| +// DecryptBytes opens raw iv(12)||ciphertext. |
| +func DecryptBytes(key, data []byte) ([]byte, error) { |
| + gcm, err := newGCM(key) |
| + if err != nil { |
| + return nil, err |
| + } |
| + if len(data) < 12 { |
| + return nil, fmt.Errorf("e2ee: blob too short") |
| + } |
| + return gcm.Open(nil, data[:12], data[12:], nil) |
| +} |
| + |
| +// EncryptJSON is crypto.js encrypt: JSON-encode v, seal, base64url the result. |
| +func EncryptJSON(key []byte, v any) (string, error) { |
| + pt, err := json.Marshal(v) |
| + if err != nil { |
| + return "", err |
| + } |
| + out, err := EncryptBytes(key, pt) |
| + if err != nil { |
| + return "", err |
| + } |
| + return B64uEncode(out), nil |
| +} |
| + |
| +// DecryptJSON is crypto.js decrypt: open a base64url blob into v. |
| +func DecryptJSON(key []byte, blob string, v any) error { |
| + data, err := B64uDecode(blob) |
| + if err != nil { |
| + return err |
| + } |
| + pt, err := DecryptBytes(key, data) |
| + if err != nil { |
| + return err |
| + } |
| + return json.Unmarshal(pt, v) |
| +} |
| + |
| +// ---- invite key-wrapping (see docs/invite-redesign.md) ------------------ |
| + |
| +// RandomSecret mints a fresh 128-bit invite secret T (base64url). |
| +func RandomSecret() (string, error) { |
| + b := make([]byte, 16) |
| + if _, err := rand.Read(b); err != nil { |
| + return "", err |
| + } |
| + return B64uEncode(b), nil |
| +} |
| + |
| +func hkdfBits(ikm []byte, info string, n int) ([]byte, error) { |
| + out := make([]byte, n) |
| + // WebCrypto's empty salt and Go's nil salt both mean zeros[hashLen] (RFC 5869). |
| + _, err := io.ReadFull(hkdf.New(sha256.New, ikm, nil, []byte(info)), out) |
| + return out, err |
| +} |
| + |
| +// Invite is crypto.js deriveInvite's result: everything derives from T, each |
| +// use domain-separated, so the server (which stores only ID + ClaimHash) can |
| +// neither claim the invite nor unwrap what's wrapped under WrapKey. |
| +type Invite struct { |
| + ID string // server lookup id |
| + WrapKey []byte // AES key the group key is wrapped under |
| + ClaimSecret string // presented when claiming; proves knowledge of T |
| + ClaimHash string // what the server stores (lowercase hex SHA-256 of ClaimSecret) |
| +} |
| + |
| +// DeriveInvite mirrors crypto.js deriveInvite for the secret T (base64url). |
| +func DeriveInvite(tB64 string) (Invite, error) { |
| + t, err := B64uDecode(tB64) |
| + if err != nil { |
| + return Invite{}, err |
| + } |
| + id, err := hkdfBits(t, "lchat-invite-id", 32) |
| + if err != nil { |
| + return Invite{}, err |
| + } |
| + wrapKey, err := hkdfBits(t, "lchat-invite-wrap", 32) |
| + if err != nil { |
| + return Invite{}, err |
| + } |
| + claim, err := hkdfBits(t, "lchat-invite-claim", 32) |
| + if err != nil { |
| + return Invite{}, err |
| + } |
| + claimSecret := B64uEncode(claim) |
| + sum := sha256.Sum256([]byte(claimSecret)) |
| + return Invite{ |
| + ID: B64uEncode(id), |
| + WrapKey: wrapKey, |
| + ClaimSecret: claimSecret, |
| + ClaimHash: hex.EncodeToString(sum[:]), |
| + }, nil |
| +} |
| + |
| +// WrapGroupKey wraps a group key (base64url raw) under an invite's WrapKey. |
| +func WrapGroupKey(groupKeyB64 string, wrapKey []byte) (string, error) { |
| + raw, err := B64uDecode(groupKeyB64) |
| + if err != nil { |
| + return "", err |
| + } |
| + out, err := EncryptBytes(wrapKey, raw) |
| + if err != nil { |
| + return "", err |
| + } |
| + return B64uEncode(out), nil |
| +} |
| + |
| +// UnwrapGroupKey recovers the group key (base64url raw). |
| +func UnwrapGroupKey(wrappedB64 string, wrapKey []byte) (string, error) { |
| + data, err := B64uDecode(wrappedB64) |
| + if err != nil { |
| + return "", err |
| + } |
| + raw, err := DecryptBytes(wrapKey, data) |
| + if err != nil { |
| + return "", err |
| + } |
| + return B64uEncode(raw), nil |
| +} |
| + |
| +// ---- user keypairs + ECIES (see docs/threads-and-dms.md) ---------------- |
| + |
| +// Keypair carries a user identity like the browser session does: the public |
| +// key as the raw uncompressed P-256 point (65 bytes), the private key as |
| +// PKCS#8 — both base64url, interchangeable with WebCrypto's raw/pkcs8 exports. |
| +type Keypair struct { |
| + PublicKey string |
| + PrivateKey string |
| +} |
| + |
| +// GenerateUserKeypair mirrors crypto.js generateUserKeypair. |
| +func GenerateUserKeypair() (Keypair, error) { |
| + priv, err := ecdh.P256().GenerateKey(rand.Reader) |
| + if err != nil { |
| + return Keypair{}, err |
| + } |
| + pkcs8, err := x509.MarshalPKCS8PrivateKey(priv) |
| + if err != nil { |
| + return Keypair{}, err |
| + } |
| + return Keypair{ |
| + PublicKey: B64uEncode(priv.PublicKey().Bytes()), |
| + PrivateKey: B64uEncode(pkcs8), |
| + }, nil |
| +} |
| + |
| +func parsePub(pubB64 string) (*ecdh.PublicKey, error) { |
| + raw, err := B64uDecode(pubB64) |
| + if err != nil { |
| + return nil, err |
| + } |
| + return ecdh.P256().NewPublicKey(raw) |
| +} |
| + |
| +func parsePriv(privB64 string) (*ecdh.PrivateKey, error) { |
| + der, err := B64uDecode(privB64) |
| + if err != nil { |
| + return nil, err |
| + } |
| + key, err := x509.ParsePKCS8PrivateKey(der) |
| + if err != nil { |
| + return nil, err |
| + } |
| + switch k := key.(type) { |
| + case *ecdsa.PrivateKey: // NIST-curve PKCS#8 parses as ecdsa; convert |
| + return k.ECDH() |
| + case *ecdh.PrivateKey: |
| + return k, nil |
| + default: |
| + return nil, fmt.Errorf("e2ee: unsupported private key type %T", key) |
| + } |
| +} |
| + |
| +// eciesKey mirrors crypto.js eciesKey: ECDH -> HKDF-SHA256 -> AES-256 key. |
| +func eciesKey(priv *ecdh.PrivateKey, pub *ecdh.PublicKey) ([]byte, error) { |
| + secret, err := priv.ECDH(pub) |
| + if err != nil { |
| + return nil, err |
| + } |
| + return hkdfBits(secret, "lchat-thread-wrap", 32) |
| +} |
| + |
| +// sealTo produces the shared ECIES wire shape: ephPub(65) || iv(12) || ct. |
| +func sealTo(recipientPubB64 string, pt []byte) (string, error) { |
| + recipient, err := parsePub(recipientPubB64) |
| + if err != nil { |
| + return "", err |
| + } |
| + eph, err := ecdh.P256().GenerateKey(rand.Reader) |
| + if err != nil { |
| + return "", err |
| + } |
| + aesKey, err := eciesKey(eph, recipient) |
| + if err != nil { |
| + return "", err |
| + } |
| + body, err := EncryptBytes(aesKey, pt) |
| + if err != nil { |
| + return "", err |
| + } |
| + return B64uEncode(append(eph.PublicKey().Bytes(), body...)), nil |
| +} |
| + |
| +func openFrom(sealedB64, privKeyB64 string) ([]byte, error) { |
| + buf, err := B64uDecode(sealedB64) |
| + if err != nil { |
| + return nil, err |
| + } |
| + if len(buf) < 65+12 { |
| + return nil, fmt.Errorf("e2ee: sealed blob too short") |
| + } |
| + ephPub, err := ecdh.P256().NewPublicKey(buf[:65]) |
| + if err != nil { |
| + return nil, err |
| + } |
| + priv, err := parsePriv(privKeyB64) |
| + if err != nil { |
| + return nil, err |
| + } |
| + aesKey, err := eciesKey(priv, ephPub) |
| + if err != nil { |
| + return nil, err |
| + } |
| + return DecryptBytes(aesKey, buf[65:]) |
| +} |
| + |
| +// WrapThreadKey wraps a thread's AES key (base64url raw) to a recipient's |
| +// public key, exactly like crypto.js wrapThreadKey. |
| +func WrapThreadKey(threadKeyB64, recipientPubB64 string) (string, error) { |
| + raw, err := B64uDecode(threadKeyB64) |
| + if err != nil { |
| + return "", err |
| + } |
| + return sealTo(recipientPubB64, raw) |
| +} |
| + |
| +// UnwrapThreadKey recovers a thread key (base64url raw) with your private key. |
| +func UnwrapThreadKey(wrappedB64, privKeyB64 string) (string, error) { |
| + raw, err := openFrom(wrappedB64, privKeyB64) |
| + if err != nil { |
| + return "", err |
| + } |
| + return B64uEncode(raw), nil |
| +} |
| + |
| +// SealForPub seals a JSON-serialisable value to a public key — wrapThreadKey's |
| +// sibling for capability grants (docs/connected-services.md). |
| +func SealForPub(recipientPubB64 string, v any) (string, error) { |
| + pt, err := json.Marshal(v) |
| + if err != nil { |
| + return "", err |
| + } |
| + return sealTo(recipientPubB64, pt) |
| +} |
| + |
| +// OpenSealed opens a SealForPub blob into v with your private key. |
| +func OpenSealed(sealedB64, privKeyB64 string, v any) error { |
| + pt, err := openFrom(sealedB64, privKeyB64) |
| + if err != nil { |
| + return err |
| + } |
| + return json.Unmarshal(pt, v) |
| +} |
diff --git a/internal/e2ee/e2ee_test.go b/internal/e2ee/e2ee_test.go| new file mode 100644 |
| index 0000000..a92b290 |
| --- /dev/null |
| +++ b/internal/e2ee/e2ee_test.go |
| @@ -0,0 +1,242 @@ |
| +package e2ee |
| + |
| +import ( |
| + "bytes" |
| + "encoding/json" |
| + "os" |
| + "reflect" |
| + "testing" |
| +) |
| + |
| +// vectors.json is generated from web/static/crypto.js by |
| +// test/gen-e2ee-vectors.mjs — every blob here was produced by the code the |
| +// browser actually runs. These tests are the cross-language contract. |
| +type vectorFile struct { |
| + AESJSON []struct { |
| + Key string `json:"key"` |
| + Plaintext json.RawMessage `json:"plaintext"` |
| + Blob string `json:"blob"` |
| + } `json:"aesJSON"` |
| + AESBytes []struct { |
| + Key string `json:"key"` |
| + PlaintextB64 string `json:"plaintextB64"` |
| + BlobB64 string `json:"blobB64"` |
| + } `json:"aesBytes"` |
| + Invites []struct { |
| + T string `json:"t"` |
| + ID string `json:"id"` |
| + ClaimSecret string `json:"claimSecret"` |
| + ClaimHash string `json:"claimHash"` |
| + GroupKey string `json:"groupKey"` |
| + WrappedGroupKey string `json:"wrappedGroupKey"` |
| + } `json:"invites"` |
| + ThreadWraps []struct { |
| + PublicKey string `json:"publicKey"` |
| + PrivateKey string `json:"privateKey"` |
| + ThreadKey string `json:"threadKey"` |
| + Wrapped string `json:"wrapped"` |
| + } `json:"threadWraps"` |
| + Seals []struct { |
| + PublicKey string `json:"publicKey"` |
| + PrivateKey string `json:"privateKey"` |
| + Plaintext json.RawMessage `json:"plaintext"` |
| + Sealed string `json:"sealed"` |
| + } `json:"seals"` |
| +} |
| + |
| +func loadVectors(t *testing.T) vectorFile { |
| + t.Helper() |
| + raw, err := os.ReadFile("testdata/vectors.json") |
| + if err != nil { |
| + t.Fatalf("read vectors (regenerate with `node test/gen-e2ee-vectors.mjs`): %v", err) |
| + } |
| + var v vectorFile |
| + if err := json.Unmarshal(raw, &v); err != nil { |
| + t.Fatalf("parse vectors: %v", err) |
| + } |
| + if len(v.AESJSON) == 0 || len(v.AESBytes) == 0 || len(v.Invites) == 0 || |
| + len(v.ThreadWraps) == 0 || len(v.Seals) == 0 { |
| + t.Fatal("vectors.json is missing sections — regenerate it") |
| + } |
| + return v |
| +} |
| + |
| +// jsonEqual compares two JSON documents structurally, so JS/Go key order and |
| +// number formatting differences don't matter. |
| +func jsonEqual(t *testing.T, a, b []byte) bool { |
| + t.Helper() |
| + var av, bv any |
| + if err := json.Unmarshal(a, &av); err != nil { |
| + t.Fatalf("bad JSON: %v", err) |
| + } |
| + if err := json.Unmarshal(b, &bv); err != nil { |
| + t.Fatalf("bad JSON: %v", err) |
| + } |
| + return reflect.DeepEqual(av, bv) |
| +} |
| + |
| +func TestVectorsAESJSON(t *testing.T) { |
| + for i, c := range loadVectors(t).AESJSON { |
| + key, err := ImportKey(c.Key) |
| + if err != nil { |
| + t.Fatalf("case %d: %v", i, err) |
| + } |
| + var got json.RawMessage |
| + if err := DecryptJSON(key, c.Blob, &got); err != nil { |
| + t.Fatalf("case %d: decrypt JS blob: %v", i, err) |
| + } |
| + if !jsonEqual(t, got, c.Plaintext) { |
| + t.Errorf("case %d: got %s, want %s", i, got, c.Plaintext) |
| + } |
| + } |
| +} |
| + |
| +func TestVectorsAESBytes(t *testing.T) { |
| + for i, c := range loadVectors(t).AESBytes { |
| + key, err := ImportKey(c.Key) |
| + if err != nil { |
| + t.Fatalf("case %d: %v", i, err) |
| + } |
| + blob, err := B64uDecode(c.BlobB64) |
| + if err != nil { |
| + t.Fatalf("case %d: %v", i, err) |
| + } |
| + want, err := B64uDecode(c.PlaintextB64) |
| + if err != nil { |
| + t.Fatalf("case %d: %v", i, err) |
| + } |
| + got, err := DecryptBytes(key, blob) |
| + if err != nil { |
| + t.Fatalf("case %d: decrypt JS bytes: %v", i, err) |
| + } |
| + if !bytes.Equal(got, want) { |
| + t.Errorf("case %d: plaintext mismatch (%d bytes)", i, len(want)) |
| + } |
| + } |
| +} |
| + |
| +// TestVectorsInvites is the strongest cross-check: deriveInvite is fully |
| +// deterministic, so Go must reproduce the JS outputs exactly. |
| +func TestVectorsInvites(t *testing.T) { |
| + for i, c := range loadVectors(t).Invites { |
| + inv, err := DeriveInvite(c.T) |
| + if err != nil { |
| + t.Fatalf("case %d: %v", i, err) |
| + } |
| + if inv.ID != c.ID { |
| + t.Errorf("case %d: id: got %s, want %s", i, inv.ID, c.ID) |
| + } |
| + if inv.ClaimSecret != c.ClaimSecret { |
| + t.Errorf("case %d: claimSecret mismatch", i) |
| + } |
| + if inv.ClaimHash != c.ClaimHash { |
| + t.Errorf("case %d: claimHash: got %s, want %s", i, inv.ClaimHash, c.ClaimHash) |
| + } |
| + gk, err := UnwrapGroupKey(c.WrappedGroupKey, inv.WrapKey) |
| + if err != nil { |
| + t.Fatalf("case %d: unwrap JS group key: %v", i, err) |
| + } |
| + if gk != c.GroupKey { |
| + t.Errorf("case %d: group key mismatch", i) |
| + } |
| + } |
| +} |
| + |
| +func TestVectorsThreadWraps(t *testing.T) { |
| + for i, c := range loadVectors(t).ThreadWraps { |
| + got, err := UnwrapThreadKey(c.Wrapped, c.PrivateKey) |
| + if err != nil { |
| + t.Fatalf("case %d: unwrap JS-wrapped thread key: %v", i, err) |
| + } |
| + if got != c.ThreadKey { |
| + t.Errorf("case %d: thread key mismatch", i) |
| + } |
| + // And the reverse direction: Go wraps to the JS-generated public key, |
| + // Go unwraps with the JS-exported pkcs8 private key. This exercises |
| + // Go's parse of WebCrypto's raw + pkcs8 exports on the wrap path too. |
| + rewrapped, err := WrapThreadKey(c.ThreadKey, c.PublicKey) |
| + if err != nil { |
| + t.Fatalf("case %d: wrap to JS pubkey: %v", i, err) |
| + } |
| + back, err := UnwrapThreadKey(rewrapped, c.PrivateKey) |
| + if err != nil { |
| + t.Fatalf("case %d: unwrap own wrap: %v", i, err) |
| + } |
| + if back != c.ThreadKey { |
| + t.Errorf("case %d: rewrap round-trip mismatch", i) |
| + } |
| + } |
| +} |
| + |
| +func TestVectorsSeals(t *testing.T) { |
| + for i, c := range loadVectors(t).Seals { |
| + var got json.RawMessage |
| + if err := OpenSealed(c.Sealed, c.PrivateKey, &got); err != nil { |
| + t.Fatalf("case %d: open JS seal: %v", i, err) |
| + } |
| + if !jsonEqual(t, got, c.Plaintext) { |
| + t.Errorf("case %d: got %s, want %s", i, got, c.Plaintext) |
| + } |
| + } |
| +} |
| + |
| +func TestRoundTripsAndWireShape(t *testing.T) { |
| + key, err := GenerateKey() |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + blob, err := EncryptJSON(key, map[string]string{"text": "round trip"}) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + var out map[string]string |
| + if err := DecryptJSON(key, blob, &out); err != nil { |
| + t.Fatal(err) |
| + } |
| + if out["text"] != "round trip" { |
| + t.Errorf("got %q", out["text"]) |
| + } |
| + |
| + kp, err := GenerateUserKeypair() |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if raw, _ := B64uDecode(kp.PublicKey); len(raw) != 65 || raw[0] != 0x04 { |
| + t.Errorf("public key is not a 65-byte uncompressed P-256 point") |
| + } |
| + |
| + threadKey := B64uEncode(key) |
| + wrapped, err := WrapThreadKey(threadKey, kp.PublicKey) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + // The ECIES wire shape crypto.js expects: ephPub(65) || iv(12) || ct. |
| + if raw, _ := B64uDecode(wrapped); len(raw) < 65+12+len(key)+16 || raw[0] != 0x04 { |
| + t.Errorf("wrapped blob has wrong shape (len %d)", len(wrapped)) |
| + } |
| + back, err := UnwrapThreadKey(wrapped, kp.PrivateKey) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if back != threadKey { |
| + t.Error("thread key round trip mismatch") |
| + } |
| + |
| + // Wrong recipient must fail, not silently succeed. |
| + other, err := GenerateUserKeypair() |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if _, err := UnwrapThreadKey(wrapped, other.PrivateKey); err == nil { |
| + t.Error("unwrap with the wrong private key succeeded") |
| + } |
| + |
| + inv1, err := DeriveInvite("AAAAAAAAAAAAAAAAAAAAAA") // 16 zero bytes |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + inv2, _ := DeriveInvite("AAAAAAAAAAAAAAAAAAAAAA") |
| + if inv1.ID != inv2.ID || inv1.ClaimHash != inv2.ClaimHash { |
| + t.Error("DeriveInvite is not deterministic") |
| + } |
| +} |
diff --git a/internal/e2ee/testdata/vectors.json b/internal/e2ee/testdata/vectors.json| new file mode 100644 |
| index 0000000..4d33dcb |
| --- /dev/null |
| +++ b/internal/e2ee/testdata/vectors.json |
| @@ -0,0 +1,127 @@ |
| +{ |
| + "aesJSON": [ |
| + { |
| + "key": "6_Ldkj5Am59KHgQhDUveCzt3kMGwH34SSSW5Ik4cJN0", |
| + "plaintext": {}, |
| + "blob": "huLEWVhp_iI8C8wN30myVHhbS6ooU7_OW4T4JN7n" |
| + }, |
| + { |
| + "key": "nmZbNxeDzq9BoQn1vBMa9bVfyhEeRdk8SLOAbWqi-No", |
| + "plaintext": { |
| + "text": "hello world", |
| + "from": "Paul" |
| + }, |
| + "blob": "CcKmzdHYIfogZNFHtwATlHaYfBosRK-BXWtFheRLU8eq8lY4PW27OJFYhel81ttuWLrnOYgdMslbKtCrywFhlQ" |
| + }, |
| + { |
| + "key": "AbGxvzLu97jpID_EOShOWVZCayzH6SYhA3jLjZrtyBU", |
| + "plaintext": { |
| + "text": "unicode: ünïcødé 🎉 — em-dash, \"quotes\", \\backslash\\, \nnewline" |
| + }, |
| + "blob": "13iYnPyf2qTfs91vxOpeMzGY0YpYuF7RETQFEhzKOOCAg7LF0i86d8KB5gKFh3WN5u0zSBdSFW60iw1eMMHVNU07tlgZnmd9HMRWMLyjeo_59s0PKYIW5UrhNYLzHVYakpmIYAz7AVkfxhURBQSUaeSC" |
| + }, |
| + { |
| + "key": "niG5WsQHy2qxgAzEr2WHubCflIr2js-6LSqq6Xyv66Q", |
| + "plaintext": { |
| + "nested": { |
| + "deep": [ |
| + 1, |
| + 2.5, |
| + true, |
| + null, |
| + "x" |
| + ] |
| + }, |
| + "n": 1747 |
| + }, |
| + "blob": "TKGExtbsjCQk2ylVrarcb68adBPqoeYs39E5kB9tNKxV44zfU3sRMM0QLyBPAi2FiD0Lj3HnsdEG07wGcR5XPzCZg3FL8b9ibN6_0wXM" |
| + } |
| + ], |
| + "aesBytes": [ |
| + { |
| + "key": "9Fupm33Zb40ffgHE72fyMVwbBU-aKPWMtUQkOeH9YpU", |
| + "plaintextB64": "", |
| + "blobB64": "O7_eAHqomwMoYyNcM8z0qa1Qb9m1Q1dmTH76Gg" |
| + }, |
| + { |
| + "key": "ckX2zqiN8Snjs5nJRjax1AGa66nO3LG_MOoTXlp4uXA", |
| + "plaintextB64": "OQ", |
| + "blobB64": "6EJhX0Zai6cVBcFOHWVb1iLz1LMcSEf_TwYdzE8" |
| + }, |
| + { |
| + "key": "8RlIabDpH0a62tMLj4U1Li0y7d5M7rwBnwRnJC6X1Zk", |
| + "plaintextB64": "Y5rw9ZT5huN0b_BTfw", |
| + "blobB64": "KF8Kpr2cXAQKSSYTsWD_kASdqLxmknr8IKuT7CAfpOe-rlB2e6O0vos" |
| + }, |
| + { |
| + "key": "dRgGtJkVnaBlxwZau2f8ldPntgN4pWCFRj5xUfmWlec", |
| + "plaintextB64": "n29DZ0K3Mai1mft0jC-rNpvWUD7fe1gp3nfebdI1P689iByyJDNa8LgV4yp1AEmvQEWLigt0rVcB2YS2BN1Q9uKrIC5QjhAOnwRyjtlwqsYnw7wAc0otXoqE7ZdbUQjOE9eVG9YiAiYB8z4ozxrcds3H0TpodD0b9ExJbH64iFUMHD6m2tzjFXh2IJYp6zT3tagFUh_FdK2oZUUm5etZl69xrt6It4SEYI0RjINJy433l2s1PXRPEvczIH-VkaWYVTJRxd-yIdA34Ikq6AXHE0DYoRRcaGzbwnHDhUCSgr_XCnpf3mBrBh0NPrB5nOJTRP7SHC-gUGjsaTT5RxR0ZLdUkNjN5_19SrsTLrafAiu_moCn4ptdaywR7fsHyd8rAYs64N4nPfy06tYK2IuPCiyG8os6z-ddkv2MkCsikfaDIQg3fypNLFfvvOvoicBMzT-Vg2MgaWg5LWh4FEu8DXUV9qNA6noB4oJenqwq6JOl0pNWVv87GOTf0EgvgY25gxm4imgE83oF2rLGlI_hRJZC84PTfk9q0XNg7yTMUBBA6R-LxQQ7Io1yF3f68dIPE3kBvtcEGN_G4vWYgjz2P9kiZTJPn19DDwMqrqrVMKeqM4wd1sNCbd2CKLUzpBV5girszBD8HsCdSMb5qiQUeJfoeoGO6IJTz70JFDR3tmt7gYkZqvC1WfH0RteECi0_1e44scBxg8g61dJha-JEwdIvxLaMhy6FRMNJg1iqQggp42vaIYsujvYuTnUfpBIxfqOpPvVHkkUlQcAlmhCYXm_lmFEN9xr16L1ySVaUR48utc6ZrG09Lc1mw6jUT-HIbmcdNnebB1fuPitSyEX4QWKLqQH-GX7qyEiyppnCHhaIJdTxOj8QmZXUQ6JUGSwDHijtKCxpiuPv3r8IIrf_zdpBfrH6z0Q4mE0rOc59Ypks4tXb7bllbePF0nZDIdSkPZLfEBGw8BWE3kS50rzkkXSdaZrmSJwC0__e0kxIuLK_YBOE0fqivhlddvZaFoqvvrz-EVN6bB4GoMHfDJEX4LjhiM6ouo5GCXFIEWLFKoybjLjROVF3PZAtGQFPNqlqG1qIKQLZdmNuo4VnjiuZlCIae3ttesJx3G-hwzoS0h4OKZY894KCrUUFYPioXDdLKGfaB9WHtlEGYPXXd1JI0SXOzhaq8kCbWxdL9g4xDl9ggNx83HhkoqSM0uogn_yEbWhZTQQDDr42QTpeQ0py3st8yeVywZ0Mwnq5M2kr7eHhpBRBFSGu-lWlAdj2XOLpHmr5TQ37udLg1yL-FuAV-LEULPlO168SQbpOrbIgeICoztZcdqPHzwTH0Yvi0RnXslvwXxk9Usjcf3mzK_bD3iaot0DwW490LpbRRpt3-rc0ZNG9kVqe6J_tY_GYJ06JGtBwsYD9nE8hQDM1hqu9V561xSSrKQx3XNjwcAO1pRYKlILFpTFI9vzpLKhu6XssM_GWKPYN7t0BAPFKL5plS8bEw31mo7XBOU-mKRbOIOoL00CtXWn5bbdz3q9s-BR6vAMc6wpWcrBn-_XyxOfBZeAeFC8-Lj-A-Y2Kdlb5qZlo9pqpYDGrk65WnCziRaheWWtA5aZNDoG_fsB6ewSG6R_hTMlAd2ismFGox_hBrOmOcsT1tX8GDqsdSsEOla4zIX1FPhJB7miFfBjdRMhFlE9SKu0JqF_yMj9gImYycy4mQqwhOV6XdpKRshCWyxh75-R51SUt1-gtFLgHFnPcP0tuEhLGMzLYm6_EV2nnTY-gIA5sQ6VoaAiqJ2aZY21gg_15fdjRYvfFDTyqjff-vpJQ_DfjSrWAq3lP7mO1kVv02eqe1HuHsiuCDBCfCHnlI13oADp4XruFH7hg1BAnuO1fb-6_pvTqSxKN1hZUK7ps5hvoYZJN7IZsp0cQFX2ontG0xAbxFOvBUuo8vugtrRAMGLxgS0bcyJQIpGvfe3ib0_YxxioO2REn1oGenRdu1CtVaKeGNPrsIBahe5MUb-23qEMceyDtPyr4RMD_XXB_z4dFF_-AvTmxWiVj7Pf2XgMQQkLfo3p-fQFF7XXr2JQYQgE_AmEzRnFh_tCrLPROGbFLgF3NVaMXSIySAo42w-a0IDSuOzgUedtQM9mLUUiRyKY7J32wS8WQm66w-MJuFkHf2YPcwbO8eR3wY0rswhxgv47aFygcj9Jn2GSNTxnc19ES5rnE3rEB-hN082Lbh0W5wPBecnoc3Y9VHEhCezhLJJWBCHjXcf2X0it00plxRCAItXiwXV4-GEHLk46z1pAu-uV4b-qemCbbZrm62AJaOOzVKtkuBuZuY3A8Nv6y7JuljEYfHZhuo1BBty4497b89n3lernuyZfNCdP8HKg6zfjVIGqiRFW8BsIKoyCeJailUZz5gO9LYOHTQVGzWHeQ1AetUuzlxzBrzGHzTCImoR_lg8itB5owNQCq4dOQqaBqXM7yqgHHWISKJqnC9NNRwgeRnwMd8HKXHernX0TjyajFeBy-HPJMafBMODN1pDcdMFehdGOUoUWkbaLbH6yaqa6_7-aJai4yLn9rtzI5Rh1iYW41MQlveXyUm9nZGMEWb7rrfkQvxv919as6Zd3vl8UmXz_PhHdENSNgBGqPQkJCDVVITlsVCxKp40X1cLtqXI1gXr89zTy_jtcQcyoA1e6H8vfBFgs5ZNrEROkKL2MfnMGa0fYSa2PRKhNFPuXcvjirnN4TM9Io1_k6U-NG_fX60xUvmZClb4F3BTu1p-UMN0jEleYsomQT3WmbzmMuh8K6HZZb9djiYQhDgRd79SYAXlyKbXnD-zWELkvokvogzJTicSB7OXYC4X3zcCcsJttyJYhSVVlEf4v7btCSz0u5ukVTy_Q3lCChdQ0iRmdJcGtRFiFNNERVCdigUF9H0Rj3whfO3v61pOTwANjFKOuYFQHb__Qe3EgTKbr2fADLZIuktX5jUJFx0Y1WQ_cRRRsOJ1cjcvi6rYn8i3XF1X1XctFdRIuiWHCtCS4VER5_qIGJ0OPo1VzIquHJIWHKmZVZiZhmKVKVIY_HtB_54vvHh50om5VQ1Hb29mNFJrtK47koW0lpG69c7tXjPpJBjZH4Tj-ElmdezZG-nZdzXhJPVw1R30h8IpNR6AtZupaqjqYFsvVqyum0P1iLsic9p6bTMX0bzhdbMs_To028YmzjBwu_lBpM_g48KjG77bXTDDAj1n1vWx5-lNL7gA7Xn-uo-DetMaTYV-bFZ39L0TSw8BUej9m-uBsAoODxEfv2XTZijoZO1czOmHYIIJ3Oy3I9l5YID2Xamwtp1RQVI_lui3V8TN0R66FNmOaPAnWS_pienG6DZ-G4W4RD4j8FgjhLprF5FYODetr--b8Xd2A4ggwdcA-nUfuG_uxLNgqpijiX0qdm6faJSi4gsn53TeqKuc4OlyQ7jv5-0rQK5A_VGzOJ27zUhvAZSyjwaYEVqx73R6yzAvRk8z3SK7cz6_elJJ3WriWTsWzrP6LhGBW06UsBoK9eFyiVUyzQISanD1OiEQdFJ9Y8R2fkrTkYPh5g7XMnxqzfOY_V0DoMtYUw4BJ5KToAoziovs3HraIJP_Ut737WhfbO-_9UToXGp6_IWjiM60x1Y09UA4o9QdsrffEaq8BFOCDzX6tdO4iGeGyiSiSeZVhjYVG5Lw90RVjmHUdtEw_9LBn6vHRZmQsfYReO8ESWI9LkMg0iZSQxaeShioozg9q9v23VdAeURQSRK6C8_CcBguTnbCo6YL-LBou61pYQ3Hcgy5oKYnyWUJR3C8IiEyaA1vDae5sEHdhiXIQ7ZEPRZ-PsTxkH8LVJH6b16nB5ZEPce-S6bpmz1HKj2W2NSnFVrQqtUDI-XPf_FGnLjx2LRPdgYcQKA_D2Migw5dhl5er1jVaDINI7S5UyGR5IVeXJvFklhL-Z76bsVOXFhNmc19oZytaMEnuCIUHlu1-FgLsLW1Uy1ZdpsWRo8oaRVV195ur5anGaif0CNsBWkxI-OJzgKJM7UwbVbdcT8YrM5LxUSFcfayNCfJlIBTOMnGNjr7fC5dzLHE887IqCDecVUGdDom41JWcv9fPRouMVJHThWQCC-lHesK-StfNWD5ZLbmOtMlaoToNbzMDkJ44_pY66vfqwYJE2Tg6OAr4WonayolabXBgPPxViCfiRwbfaGoihGjj69po49ib-FDGTZkm9lFPWd7fVgA2xY_MYAPTE9gjuQXwnESa9ri6ICr49W0HonTkWQlJtj8pYT76oITTotvfnzIxPy_L8VqYI_AkUu8cKhR-ttkHiFUt0EUl-qYulti9a_2AtCbqVRkub0uM55ClgfV0qe2WBtQDDFHpVI-cseG6dsX4Hnuxjdgd5lpbveiyS92aP-adE7KKK73CsiB2MeijDCrjAF4RuiVwdmL-u0Szxckv_5bzOxpFpeW05YHwwW_V1s1A4mYOFSbJMYORu6faQNMurRraivSCPY_6_cDqDrPf2lF5AtApe9MwpKvkxDZcaZJqAKoAq9J0OnrFCyBWMhO-lgNfx3sPHf6xUIY3sbIWJn78B5EZYLY-fma0bQ8z7xhQAeLFwkuyvTkia90TixWUTGHKJA_P3b2kOXVE9yc70b5ZKCWcMjCW9LRa-UyMArng6rO0aIkx9zslK_kVr6YS4WIR5VzLs-tqV0wFersq2cxuzl9HBenmIkWiSnN36IOZ3_m4L1-NYGmlHFjg50bX-ZhdVD9c94eZ7IFykQigyTSoRNlPR01PAIUMSiyQl9OghcdALLKB_TZ-cDDlTbH2WhTFkaaUqScJvhclFeJEGhcgSJe3kdYTU9WJ_R0-hqf8jovLsLAtsrHuHKUvci44QYqa8AZyZMsSvdpz0rNJ2atDKwf0dxZ1YlAJ4kxUu1Hxb15Yob4mIyYamNabTJq0AXNIsThsSulq9UvAVicJwKMy93R0sU61h1OmLDGIaQqfEZM1gORGteKJhsz-7PFVztBL9mKpDMqP482zyt3bbFYC3B4rfdcj9AotOQsDShAP24XXsWYGd34Wq-1r7qD2wdyQCBz9dofblkz2z7PvD7cdf6dKXnjZ0--8AoTt3precxak0Zb-PQkEbxy7Zep02gahnwrqZtYOD0RL02J_K1_1SyBhvZbvtyc-NqbLGzizdBX_fQOnxElPsizZLg-RtQJlFmzznxTsJ4OmiKiE5cLX6y1fEkLLrkXnYxdaM5zI1JvgIo_0-kEH94vSgRoXP5t5DFSOqOSjyCdAS5xAXeNqsiF8ngd4gFABrxuKc4w_i9YkAIeP5nDS2dw5gHLaxAOL-sL0b58rFJ1n5uiDhi_s3FL_pXCAsNkYyCDKoS316-HqaE_EOegYFiQN1ZxXIrIz-zc9k-cLBxblxPbJdGOBH-Ik-G0S3xXe_gVMgTuWsi4T2iECuJSLqWAqYYrzQrTVWs5GqHvZPa-TPSk85FHQLc0zoXA", |
| + "blobB64": "3xlps8qoylG5eDZ0wRrb-VqsjrXT-XAiCrFUTXqbCDEnFxYi3mXhZgPNw6uJ-GTosWYoQ0aR8wOAQz6uNhMQ_z1eomZ2hkQ7T9Q2Le2B7R0Ed8DdY2YzdSpQS4EqEQACjFbkAMS4FdhXH2G39l071rWrVdKrxK86AMFdHlf9GMXUzySkjBKTOBRG_Ewcw6QApi-DNh3dyRcdERgAdAmJvSDBAa_-yteHVld0JDiA3aCFb-WT62HpEvSC6w5QhsoKvb1KfcwCWGDnJWrGrMRqJX77Wqu6lJnREqMH5Tklr3t_pYvUI9W1siBjN3oRpASjBAjoNB2tMD6RVVN6EM7YjdEdewDbbGFgqrKgG-byzFZ-32FDJcNQs5RtfnN_t-ePwcVKYDdFt2_EgbaEOrT9xyxfREtKEXYAeNsaRxEbzy5W0BwNiov1mq316meW9FoUE9a5z4a-hR_ZCF1RidSUspJWQ2S0x13ek8ag2NswU6hLjy7xOWCwaRSyd-o2IMV-q0FVWBjM_QREm-xTqh3oJLiwUYw7wpct6WIIu_rKeXHkeqx2hScpKW5NFQVAlAl1XtCeBCqFtVpkRMnBbuuZVB627Abr-1YVdBe75K1R_-dDvrktVNM3FydXkD5Z4k1haiDW1yL73B8SBZZykScShiB-k8Xf3Hlx8JUTQOYguGD0XayCY4INhGAweOm__rWidLZkyxHgF37ywHQtfPt2mGgmR9bQKVpyqkxUfs95R_uvl_u4wzS687X1h7LwmgGRNd2Gk0JqqxTAQOeifUj8-ucxHdSxsDrwHw8qQKLYAGc82Iasbm2m_CfRvNUoXk6vh2BTXmbzEjnHd0zE_oZxWueALpQMH5feDcAsQmXdfvQBirlZ0ooltYc0tlGYA4UhdT-5OgpsamKxTXtHZHMCOK32Z2Qoa0mqay3EDoznZWEpSXSTYdaUgNUd33zENOrRhk1ZIHgG_xM5mia123TOwn80eUBVg3Qg0oQfDJ9DHzN5JcFbVuqsmT__ldtrsDLVNiouBmHu_kwEJ6T4SWw7zhnGdsvva2AgqKOUTmctOZArhN9AbAm9opFXnjcsQfOx8R1hoOKHwl72m73PILGl87M8IDYBW8J5F2yKc2Hakd90DDlvUQbCC2HlKwcsYP5GFVd1xfB45mGef8wkqiXNLOAjTYta-Hm6yn4JcC4xiBALtMiDbumvApC-9elQrMDG8_UfjTtxlxwiObMcr7LrCI5N6Q_4ROskArKaNDZNYB2YvmJWuSZ_8KXuF5h5PWl-5aHT9Jh38QF94u0ew9J-4DYsdpq2GjBY5ZU37ow49zTfglMKC7fAgvNp7IIBDOWtqMI0n_1h5eGr_MMyw9PaHfUn78flZWfnESSOe_puYjn5lXJqcbpERymLdXCSLhnloWXz7H2OrEjG32yu1TudYZML7o6QmBEDXUBUkELEE6Fqt8_Ee08yPxTj2TAJZkRm116ijUa2vfEFjDN2eEtZTUcJUxf96pYvJBUSR8ovAkWsJIWVcgsV2QYkZtYMLUPUzpCNeOQiUQj9518EpM1cljDY2t7nqyg04yB-3-x_PaZxOSHdsCFGa8qBqPN5urtd8Q-ltutmSJSfEtqnrfAImEZwxe5awSky93za6RELU0s2VkqiKZilD9eROGmGq5QnLsYcr5iiYOtFxPoQ3z2BQRXW_K5oYA8XaffiX9KwmSzMwAlYSK7sii-eMcL-wZ4PvbUVJrHYYcDqBKiOdMX-VYVikgwFKC15YemrM9LiMo5zx1FaYeUA4NjC7SGE_gGUnS49f-rXSg7odG2crASxyoh7srRMcg1Tr4kK1ph3Rn_uOkqhceJrtVamchbvHJ81h2ofxRcJ7qf47IVPos8W-DzG9Z0oH1KX8arPxH1sn07U-V92AYdfl0N9vCDzu7PZXonN2dMMyKdQm_tCfS_dEapVjmY767xO6XjuxFLB1kJ3l9caCk1EtsDAJG9d79T8b5vGrsO5JzOi3yTqOwDXdGmz2faV8yMv_dTBhRlvyMAC1eB7NkqArfv3UbhfnwEl4OoNXt0Qn8wtZybNZRDvMGEnFtp4byeXMgMzCT9lzfXELy5SED-8pg6-K-mSEnF09Fc07gOXvjQtvBCrsklG-kbTlUvCYYbP5JCW57DWP5LIxAF_mz-v25MSypi6dKU_jAAfNkNoR3yBFZrCPJuUoSR6b56IUNvhiBvTZ8z6h3Hr2Epknii9jdbkOUWwL9r_xXeyt8o-UGeLYYoUGeztuPlj3sOx23ft32Bu-foe3pdWLjEpcNh63yMJVIzEtiH3TuGhJRWw0qSIhNlGBsLd4QvpugFukDaAUk1Og-9fbRqBOeH9KJG-v3RXAdLHrFYVMebFXdItsE3xt6rGYpZVjYtP3fsmhTCWrS54pIpLgOULPYSOHUZ2eMeQefCIIacpp_4tM9Pg44Ydpao7I40xsGbQrdqRq_N2IsbEGbxXVXnQ_nZ48I4a4Qk4R4MbD7MeWsGj1-SgWvOTAnCkAChnbWd3gnwxwBOWnvAtBI-wjCVMzLoNRMj9RYPZMMs8btx3W_qmlhQlXQ9zhj-D-nZJRDTCwf0RkMO4DbfCquEtX2C2We9uL3F0w6iykgSQNpKAfFzRaArOxMVGWStwlOEFhzFdhUmYkYjZrAtQSoXLee94t3L_4uhZZgZrznRy-iVNnRZ8y7VCeHQnDHdQjrm1WamMznDyO4fJ4wzi_Tr7YDHxzj8FqAPKOBzn2h8lNBPztCfI3kDG2NOW6EwHQgUYpaUsjzy1P8hElUrTh9Y9GahjnxRVkt0EDWQzE2meixS3mO3oZDSEufxpGn9o7g5nBy3W7zK2jZP_wlulpMhNQJ4uCxP0ATc_ZYqan6miF4VhRHSxBBlVf9w2V67_POpCjox49LAwVwMA4ILNefWPMsUHTMp22ksRAI8jUaF-_n7dj-ex-6jlUnb8BWipS1bjjp2gPvARBGaIsBIcLVQEQ5HCh_-nkn-jfuju6gp8_U5fXcGObRsobN5Zz_lhV01sb3Rp-KYsl_3Aln8s8kyTpLEPtJVKRFWoXJYTg2mdvWVeZeFq45YdDd9fulAwJeIF2jtQIwVwO2VCXySOajH_s73UxwNjFXI3tptTVPU-1Rn4fqiQ15rva6xE5ielWfOF74vmEJQQjnBCJ4DXxrn0-LlOOFcMX4B3GtWIBGt_YhutgaHUv432CMwmbVMigvkpGp9VKTOe-yKQPETyktpnin8XPan8d5i9EZDBIX_Kg2TRylysVyZX-UNxByLyfNMeoBS6l2EROKlCYdmMMLp26661n-5WZRE6buxoX0J7Lmc2nRzx65_4VRIgLX-7gsRnZmTJq0Gi3FRLfbB2PQWNqM8PSvDy-f_xWRADBc6uaIkXTVkalJs8fP4FMDmhKGUWT07LuKy24yo-MNcb-k9oodXVH40l_atHiT0z9MlBf51LHaPnQZR7ICtukTvuKjD5d23a7VmyL898816Fo-NV0aCzjKmJrJQMP5le__v_j3-zzcRz-2lVH5_JBNB9YqqHX7vinwCa97Zcsz4EgJptrGbK5780QyUlNpJRHM6rOas2tBl8S0GcgXWJAy7VQVZA0ps1MVO7qfNQrf5tv2IXOMxACWVd-IXIC2Mn06XRcCy9H3V4KAWmRX38WaywL7Hy6Z-BN7UCSaJUy_0pzxWdqXgBEJ-C_4RJR_rSIQS6LdnX-5lbnf88O5ythDyCEnXia78gImtV8ghcrY6sDiWrD8wmYCZYhWlzupDCjSs3uM2BBbTjao-BTaCL_pztxW4adSJoEOSUq65hgvhvkd-hKOAnyuwlTHsztJh-jKtC1YjH9OxfSJocX1niVL2ZVpapGSBOt3VHPgFLfytUqYlVaw3Ze2nBQm5tqAgHVix3X9T8Lc7HMDLRPBrZ7sVWCh47WmdqsnuhVFEw8OA2GGFdwOYE8nx-xUm2US-3AfoIHNbYdacYVs1Q8FoUw-STEho9NjGLYty8ZDbz-X1CA1HGWMwfr_MXrv1dWoIVodw8aWFonWk6EpWh7ff-frj3oej0wRr3bcBmoKJMLvQPPb-vRHyoFewCSi1Rvi6j9Saw3TJdTnUMG4zEs84uWzous4L8_JSODb8xNmvfB3FbmIU2r7Vewpt-eUVW2BFUVh9a1YoiJaipAA2ef-drvSXB0gHoBjcI-JycgIiYk6FaLMqs7US1xQLrBzx4zKB4aBrnhaXYTtPG3lQeUOQQymFgcDOXDe9J9OJFR5eR8cSq9zH3YwZfX-3yq-7_ylcJOo8sMrKqfmgGuimyaIGaAQTPzDK8CTnwozJZF51xO8AvyKBnZIQAE4LyfHlYtXUKoU2au6VbD20oJdNY_u3jrY22O61rA0u0SabmwGCajVA8qXdy-v-hqwR5jH-RBiYXxnszmIPotTi5U5Z14OM2xHw8LsR5gafUE03Arl8Ul8pYEpcrtIrfopq_WtDjssEZmcaUyro4CbygJMsL22SZz9TstEhlXCnE8GheRauXk2WfzefYtqvQ5zpOSIWbHCZD-dBLs2S1FCx2hNFZ3BT6nDvgkzInwcsPT2Aopayq9ThBJ4s-ejnkt7nrA4hPyugcIjr2DpYCG79zc1gzmn3m0Zc4ZvlHTZJ-v-HE4bMnDdJQxuLNrcO06fGzDVjMHC4XRkzMlJDKpy09xQm12oGZsqGwSQy525we3rz9FQN_Rj3qvUnPlVp49DAcSzG0-k40UiLqPRrW6kN3zdXhMVjlyGQ_Uk97-wZLzlDdmsyj9bMxzltzDofE9VRIIVDO6pwOwKZZjLwfp06jLbqI75Dw6K-ijxjtvdyo3JNOd21WzEdeqsP3vCSJ_2123aTZ0NtxHpjrd-_rgOvxZXk3rhzN8ttzGuKdL_OTHb8LavaK4iwlDfeEym-_o6oMdMRvfGt8JDaPePwwdAWLKA9npsria7KTj-mws3HieMxC4ybe-0PSSqxKhf5Ia1grfO4pDNaYm-3GQFoAGL5a0saXeDYzwmQGQ2klTNJvV6MPzam5P6peTp-F_YkE-Pf-dNUQYzvV6w7IR4il-Iq1bbvN35IVQT5HHGZnTaLvtNVIb9PeE9QIzn0jsZ6HSbyku82T7kyDmgqIoqqA_6sxjbntPNjQ4QOi_JCcXVx_7H36akQIlkFKrXYkMO717U4jGV_fShN4DAaPfeNOCByhDSsAQi3HT7rn1GM5wJEZQKVCAiCCr-wNJIbplA4wzO9zWCcsAGebO9u8gZ3yQENR8jdb7GgwWcD15K3N8o9Q8BaY2xBZGJFiTozhfddVWLwn6g_K-VIVzyDwoQMCjWNcbG6HJjUgAhy4ykCTpW9jIpJ_avPQ1hWIvQHRbqL-T3UeDQvMOrxhHinutGOSzQv96fujL2slJNC85P1MS4XEhj2U_oaszHEEQJqWTLvnUn_xeg0CMjX3QRIsTjKSmPnyM8aRU40lC4mUjqoAs83cXSUmH1JLaIHhOiyQnK1-e8fVClmyYnr_xns" |
| + } |
| + ], |
| + "invites": [ |
| + { |
| + "t": "hS6EyNbcM6POSiEOYRuJFA", |
| + "id": "KgqVX31fib7027KBBgJKY4eUoVm7nNap63XLyM_GDVI", |
| + "claimSecret": "uJW0bPRFhSEKd0kU938KKCdXqrRQNovfXGDEue0z4iA", |
| + "claimHash": "591011e5599eaaac8cdb877aec36e18e4f5fb432f2508ee4bf49641c574d774a", |
| + "groupKey": "aBitMsb8sOkylwr2oPdsSkOp_CG2poI7vpI0PEtmuKk", |
| + "wrappedGroupKey": "lyJHrHC27-zEvR1Ov8bedGxjhTcsnTeElK-usIopI1v_9Xpax92-0ZQ1j9E_ubLcxxBPbUUPugjonPUE" |
| + }, |
| + { |
| + "t": "-3HX-UTD8zKlTDWypGm1wg", |
| + "id": "yCZkZHqR1KKVnLBj0ZdFJAww60WEOy3a4OCc8xE8gfo", |
| + "claimSecret": "EmN5ncEKbzSSrtr3HzQOgryf-mkrL9NH5U6IHoA_y40", |
| + "claimHash": "ecaca3efb274d59dff7b6a9e4f2d856a68b901bab1f0eac1934d619e7e84e026", |
| + "groupKey": "ua9-Afv3cc3WR9XRHmLRNUr0QKDt7GiiMa__l4U0T7s", |
| + "wrappedGroupKey": "uSbyXzhEBXFxZ8qaVK32IVP8-XgaInhzlsoUhCb7yYTctwNqdUEPxrAbOxPfitETP6ICCVpjEuoAJyFy" |
| + }, |
| + { |
| + "t": "TVWQWhASPKpnVBSGXHOp4Q", |
| + "id": "bGG9fhhf7iRmGhl18hSkyCfMYluVnktKHoEBx3G8DMs", |
| + "claimSecret": "xm8xdTvB9pz3auRWgbKpu3EWcyDQ78vGKntN1rZT0xI", |
| + "claimHash": "4f1a71ba97b3ebbe3282a85160dc4dfe7362da06c2b97fcd85281196251d7500", |
| + "groupKey": "9YOBEM3fuECG0AQN54mtUMmKnHRUpisC-CkB8R5CdCo", |
| + "wrappedGroupKey": "y8DtAKlLEbezlppBkFvZs-YxykF0_huMIQKM4-CONrAlC78R--IdYVZkHWWvHxs1JKaoWao5LSh3HJx3" |
| + } |
| + ], |
| + "threadWraps": [ |
| + { |
| + "publicKey": "BA3a_xnoB8y_XmqbUFsvM56FrfLzEzLo1DfGGeoN8M1rknIgciVg0NlkbNHgsHBXAzxAiRCD4fAV-ioCsZFvok8", |
| + "privateKey": "MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgJNF8XIT-sS2VBOf0Rwiw4KYQr3u6luDIFOx5rLfz0ROhRANCAAQN2v8Z6AfMv15qm1BbLzOeha3y8xMy6NQ3xhnqDfDNa5JyIHIlYNDZZGzR4LBwVwM8QIkQg-HwFfoqArGRb6JP", |
| + "threadKey": "oSC9ZqGi6JDjuN4bXMSUqX6oKSVS8YfO3Vzr7vfgQj4", |
| + "wrapped": "BMXSqsrV2z8kqBSJevGF5Wu4vXHSWrKBiIHLT3iDUa0vXVkilX8RERfWH4IrNnFmr-77-AQm789TVicyjo4hBJgRZWG15gvkzfG6nr-ZoH264d5RDjU_yLwXCLWFCFUBNFnwCe8Lxh0JR18NWDdF1xS3b8c8djcBoh3CbKI" |
| + }, |
| + { |
| + "publicKey": "BLPIfnEK-El8YEU9giDghfWsUPKgc8zZfZhB60usKG0gRwQRmT_4TqXX7w277E7n-0etFyA-JV2EDsADctxMCfs", |
| + "privateKey": "MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgirOMuqA7FPFODysygWdWNA6QgZszNCezngmba6Am0cGhRANCAASzyH5xCvhJfGBFPYIg4IX1rFDyoHPM2X2YQetLrChtIEcEEZk_-E6l1-8Nu-xO5_tHrRcgPiVdhA7AA3LcTAn7", |
| + "threadKey": "p2Dohtqw4baYKkanAl7YYtnZNLGlmX3Gr3MmxN4OO5E", |
| + "wrapped": "BMi87bb2LvvV5-ohb2fEVEbVOY5Lwx0e9W2c7e3wloJ-8W0xymKG3ZiH6a8bmOdUGlk8bmDjNHuGphh9ahlJJR3B6prX6r1jVGLnZrEi-FpoFlR2k2-ZFI3iRSVIZwVAsQCFHoLAOQ1DdUFZQ956sYOLF4DO9aqjIKS3n2g" |
| + }, |
| + { |
| + "publicKey": "BJwKlGWrfxxabwiUZ-WR__0_fSOrmRTQVq-8OizPFY0BFYRqW-c1eEN64gudBVPolqPYNDkYYp0sjA3wuDvFRuM", |
| + "privateKey": "MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgNqqXccKKyidU0lQzxBu5Jq5l57Nn8CclpPxSkhQ8HvyhRANCAAScCpRlq38cWm8IlGflkf_9P30jq5kU0FavvDoszxWNARWEalvnNXhDeuILnQVT6Jaj2DQ5GGKdLIwN8Lg7xUbj", |
| + "threadKey": "jdyz75vpMGFXdawFDZYb9cAEk_UI1dqcbr7rhf6C440", |
| + "wrapped": "BEvM_bttTUW0Dt7X7zGUERH9CMu82oLg3t8nFu_0aSbwfWJbG_f5Piftx56TZKN1iwo2gd2Rf-KY41zZ4eAmHpLMCnhiJKmpedD6HMvY0MenRaElg1BlLVe2yO46qwOxxyBZew98UMKmvFae-TB4qjuWG44vPRedqKU9sWo" |
| + } |
| + ], |
| + "seals": [ |
| + { |
| + "publicKey": "BGh9SJ4-lFyfrYXKy5TcGfPFea8h3ncOgbio7JHjn56tgJN-yXF3oRiMhXCmJ9MWFkJKXOcbSg4CoVC16t4OjL4", |
| + "privateKey": "MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgqf5rKS6IzsjHblInybeitWKk1FtAMx0cGhuXqDLJW8mhRANCAARofUiePpRcn62FysuU3BnzxXmvId53DoG4qOyR45-erYCTfslxd6EYjIVwpifTFhZCSlznG0oOAqFQtereDoy-", |
| + "plaintext": { |
| + "token": "cap-abc123", |
| + "exp": 1751500000 |
| + }, |
| + "sealed": "BE3SdbJG9OHEXUnJHg0-jg8PFjoswgmaH81hsBWxjn8Ir7XUSI2JvcABp072f2v3cPpah1d5WqoO4E7IvrdLxLLjZZ4NV39GP9ULLgjEsJCvaaUBuXzNTCeQ6xz3XLVPGr7f3w30D8FUXgDzrkPnjdrTZyNckpyxknM4_9hepxvqIlgP" |
| + }, |
| + { |
| + "publicKey": "BH4cuDIGU204qT7XjWp3djn2T9M6sRW6djEodHOBf6tsaWdRHEptSCYq44_48eKNYJ1IvJLbFMgTQclr6YiUwEQ", |
| + "privateKey": "MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgvoxfxyf6_SIgLd5HsBK5jYFgyry8djymDuxDuud3_ruhRANCAAR-HLgyBlNtOKk-141qd3Y59k_TOrEVunYxKHRzgX-rbGlnURxKbUgmKuOP-PHijWCdSLyS2xTIE0HJa-mIlMBE", |
| + "plaintext": { |
| + "note": "🤖 sealed unicode ✓" |
| + }, |
| + "sealed": "BEmhUfrkXMLr8TlM0EWrsQ-4gkXNapyb568zqfoCOpdBriGozTaXuACqMueg4OVxu1oH_mgpT-K2_r-crmutrH5M4MBj5RDSLa2fk-18RFNLuMTmeaHBswixNEVJPFnucYTr1GZVRM3V2fGO6q2dJhuvnHeRkYaKtshw4ROv3A" |
| + } |
| + ] |
| +} |
diff --git a/test/gen-e2ee-vectors.mjs b/test/gen-e2ee-vectors.mjs| new file mode 100644 |
| index 0000000..7306c94 |
| --- /dev/null |
| +++ b/test/gen-e2ee-vectors.mjs |
| @@ -0,0 +1,87 @@ |
| +// 🤖 Generates internal/e2ee/testdata/vectors.json from web/static/crypto.js — |
| +// the ground truth for the Go port's wire-format compatibility. Every blob in |
| +// the file was produced by the exact code the browser runs; the Go tests must |
| +// decrypt/unwrap/open all of them and reproduce the deterministic derivations. |
| +// |
| +// Not part of `npm test` (vectors are committed). Regenerate after any |
| +// deliberate crypto.js change: |
| +// |
| +// node test/gen-e2ee-vectors.mjs # then re-run: go test ./internal/e2ee |
| +// |
| +// Randomness is fine here — the vectors carry their own keys. |
| + |
| +import { writeFileSync } from "node:fs"; |
| +import { |
| + bytesToB64url, generateKey, encrypt, encryptBytes, |
| + randomSecret, deriveInvite, wrapGroupKey, |
| + generateUserKeypair, wrapThreadKey, sealForPub, |
| +} from "../web/static/crypto.js"; |
| + |
| +const OUT = new URL("../internal/e2ee/testdata/vectors.json", import.meta.url); |
| + |
| +const aesPayloads = [ |
| + {}, |
| + { text: "hello world", from: "Paul" }, |
| + { text: "unicode: ünïcødé 🎉 — em-dash, \"quotes\", \\backslash\\, \nnewline" }, |
| + { nested: { deep: [1, 2.5, true, null, "x"] }, n: 1747 }, |
| +]; |
| + |
| +const sealPayloads = [ |
| + { token: "cap-abc123", exp: 1751500000 }, |
| + { note: "🤖 sealed unicode ✓" }, |
| +]; |
| + |
| +const vectors = { aesJSON: [], aesBytes: [], invites: [], threadWraps: [], seals: [] }; |
| + |
| +for (const obj of aesPayloads) { |
| + const { key, b64 } = await generateKey(); |
| + vectors.aesJSON.push({ key: b64, plaintext: obj, blob: await encrypt(key, obj) }); |
| +} |
| + |
| +for (const len of [0, 1, 13, 4096]) { |
| + const { key, b64 } = await generateKey(); |
| + const pt = crypto.getRandomValues(new Uint8Array(len)); |
| + vectors.aesBytes.push({ |
| + key: b64, |
| + plaintextB64: bytesToB64url(pt), |
| + blobB64: bytesToB64url(await encryptBytes(key, pt)), |
| + }); |
| +} |
| + |
| +for (let i = 0; i < 3; i++) { |
| + const t = randomSecret(); |
| + const inv = await deriveInvite(t); |
| + const groupKey = (await generateKey()).b64; |
| + vectors.invites.push({ |
| + t, |
| + id: inv.id, |
| + claimSecret: inv.claimSecret, |
| + claimHash: inv.claimHash, |
| + groupKey, |
| + wrappedGroupKey: await wrapGroupKey(groupKey, inv.wrapKey), |
| + }); |
| +} |
| + |
| +for (let i = 0; i < 3; i++) { |
| + const kp = await generateUserKeypair(); |
| + const threadKey = (await generateKey()).b64; |
| + vectors.threadWraps.push({ |
| + publicKey: kp.publicKey, |
| + privateKey: kp.privateKey, |
| + threadKey, |
| + wrapped: await wrapThreadKey(threadKey, kp.publicKey), |
| + }); |
| +} |
| + |
| +for (const obj of sealPayloads) { |
| + const kp = await generateUserKeypair(); |
| + vectors.seals.push({ |
| + publicKey: kp.publicKey, |
| + privateKey: kp.privateKey, |
| + plaintext: obj, |
| + sealed: await sealForPub(kp.publicKey, obj), |
| + }); |
| +} |
| + |
| +writeFileSync(OUT, JSON.stringify(vectors, null, 2) + "\n"); |
| +console.log(`wrote ${OUT.pathname}`); |