eleven / messenger Public

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>
Paul Campbell 926dacd613120c39c0097422e02ace4d2ccfbdf8 parent cdc4cf5
5 files changed, +813 −0
  • docs/bots.md +7 −0
  • internal/e2ee/e2ee.go +350 −0
  • internal/e2ee/e2ee_test.go +242 −0
  • internal/e2ee/testdata/vectors.json +127 −0
  • test/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}`);