| 1 | package e2ee |
| 2 | |
| 3 | import ( |
| 4 | "bytes" |
| 5 | "encoding/json" |
| 6 | "os" |
| 7 | "reflect" |
| 8 | "testing" |
| 9 | ) |
| 10 | |
| 11 | // vectors.json is generated from web/static/crypto.js by |
| 12 | // test/gen-e2ee-vectors.mjs — every blob here was produced by the code the |
| 13 | // browser actually runs. These tests are the cross-language contract. |
| 14 | type vectorFile struct { |
| 15 | AESJSON []struct { |
| 16 | Key string `json:"key"` |
| 17 | Plaintext json.RawMessage `json:"plaintext"` |
| 18 | Blob string `json:"blob"` |
| 19 | } `json:"aesJSON"` |
| 20 | AESBytes []struct { |
| 21 | Key string `json:"key"` |
| 22 | PlaintextB64 string `json:"plaintextB64"` |
| 23 | BlobB64 string `json:"blobB64"` |
| 24 | } `json:"aesBytes"` |
| 25 | Invites []struct { |
| 26 | T string `json:"t"` |
| 27 | ID string `json:"id"` |
| 28 | ClaimSecret string `json:"claimSecret"` |
| 29 | ClaimHash string `json:"claimHash"` |
| 30 | GroupKey string `json:"groupKey"` |
| 31 | WrappedGroupKey string `json:"wrappedGroupKey"` |
| 32 | } `json:"invites"` |
| 33 | ThreadWraps []struct { |
| 34 | PublicKey string `json:"publicKey"` |
| 35 | PrivateKey string `json:"privateKey"` |
| 36 | ThreadKey string `json:"threadKey"` |
| 37 | Wrapped string `json:"wrapped"` |
| 38 | } `json:"threadWraps"` |
| 39 | Seals []struct { |
| 40 | PublicKey string `json:"publicKey"` |
| 41 | PrivateKey string `json:"privateKey"` |
| 42 | Plaintext json.RawMessage `json:"plaintext"` |
| 43 | Sealed string `json:"sealed"` |
| 44 | } `json:"seals"` |
| 45 | } |
| 46 | |
| 47 | func loadVectors(t *testing.T) vectorFile { |
| 48 | t.Helper() |
| 49 | raw, err := os.ReadFile("testdata/vectors.json") |
| 50 | if err != nil { |
| 51 | t.Fatalf("read vectors (regenerate with `node test/gen-e2ee-vectors.mjs`): %v", err) |
| 52 | } |
| 53 | var v vectorFile |
| 54 | if err := json.Unmarshal(raw, &v); err != nil { |
| 55 | t.Fatalf("parse vectors: %v", err) |
| 56 | } |
| 57 | if len(v.AESJSON) == 0 || len(v.AESBytes) == 0 || len(v.Invites) == 0 || |
| 58 | len(v.ThreadWraps) == 0 || len(v.Seals) == 0 { |
| 59 | t.Fatal("vectors.json is missing sections — regenerate it") |
| 60 | } |
| 61 | return v |
| 62 | } |
| 63 | |
| 64 | // jsonEqual compares two JSON documents structurally, so JS/Go key order and |
| 65 | // number formatting differences don't matter. |
| 66 | func jsonEqual(t *testing.T, a, b []byte) bool { |
| 67 | t.Helper() |
| 68 | var av, bv any |
| 69 | if err := json.Unmarshal(a, &av); err != nil { |
| 70 | t.Fatalf("bad JSON: %v", err) |
| 71 | } |
| 72 | if err := json.Unmarshal(b, &bv); err != nil { |
| 73 | t.Fatalf("bad JSON: %v", err) |
| 74 | } |
| 75 | return reflect.DeepEqual(av, bv) |
| 76 | } |
| 77 | |
| 78 | func TestVectorsAESJSON(t *testing.T) { |
| 79 | for i, c := range loadVectors(t).AESJSON { |
| 80 | key, err := ImportKey(c.Key) |
| 81 | if err != nil { |
| 82 | t.Fatalf("case %d: %v", i, err) |
| 83 | } |
| 84 | var got json.RawMessage |
| 85 | if err := DecryptJSON(key, c.Blob, &got); err != nil { |
| 86 | t.Fatalf("case %d: decrypt JS blob: %v", i, err) |
| 87 | } |
| 88 | if !jsonEqual(t, got, c.Plaintext) { |
| 89 | t.Errorf("case %d: got %s, want %s", i, got, c.Plaintext) |
| 90 | } |
| 91 | } |
| 92 | } |
| 93 | |
| 94 | func TestVectorsAESBytes(t *testing.T) { |
| 95 | for i, c := range loadVectors(t).AESBytes { |
| 96 | key, err := ImportKey(c.Key) |
| 97 | if err != nil { |
| 98 | t.Fatalf("case %d: %v", i, err) |
| 99 | } |
| 100 | blob, err := B64uDecode(c.BlobB64) |
| 101 | if err != nil { |
| 102 | t.Fatalf("case %d: %v", i, err) |
| 103 | } |
| 104 | want, err := B64uDecode(c.PlaintextB64) |
| 105 | if err != nil { |
| 106 | t.Fatalf("case %d: %v", i, err) |
| 107 | } |
| 108 | got, err := DecryptBytes(key, blob) |
| 109 | if err != nil { |
| 110 | t.Fatalf("case %d: decrypt JS bytes: %v", i, err) |
| 111 | } |
| 112 | if !bytes.Equal(got, want) { |
| 113 | t.Errorf("case %d: plaintext mismatch (%d bytes)", i, len(want)) |
| 114 | } |
| 115 | } |
| 116 | } |
| 117 | |
| 118 | // TestVectorsInvites is the strongest cross-check: deriveInvite is fully |
| 119 | // deterministic, so Go must reproduce the JS outputs exactly. |
| 120 | func TestVectorsInvites(t *testing.T) { |
| 121 | for i, c := range loadVectors(t).Invites { |
| 122 | inv, err := DeriveInvite(c.T) |
| 123 | if err != nil { |
| 124 | t.Fatalf("case %d: %v", i, err) |
| 125 | } |
| 126 | if inv.ID != c.ID { |
| 127 | t.Errorf("case %d: id: got %s, want %s", i, inv.ID, c.ID) |
| 128 | } |
| 129 | if inv.ClaimSecret != c.ClaimSecret { |
| 130 | t.Errorf("case %d: claimSecret mismatch", i) |
| 131 | } |
| 132 | if inv.ClaimHash != c.ClaimHash { |
| 133 | t.Errorf("case %d: claimHash: got %s, want %s", i, inv.ClaimHash, c.ClaimHash) |
| 134 | } |
| 135 | gk, err := UnwrapGroupKey(c.WrappedGroupKey, inv.WrapKey) |
| 136 | if err != nil { |
| 137 | t.Fatalf("case %d: unwrap JS group key: %v", i, err) |
| 138 | } |
| 139 | if gk != c.GroupKey { |
| 140 | t.Errorf("case %d: group key mismatch", i) |
| 141 | } |
| 142 | } |
| 143 | } |
| 144 | |
| 145 | func TestVectorsThreadWraps(t *testing.T) { |
| 146 | for i, c := range loadVectors(t).ThreadWraps { |
| 147 | got, err := UnwrapThreadKey(c.Wrapped, c.PrivateKey) |
| 148 | if err != nil { |
| 149 | t.Fatalf("case %d: unwrap JS-wrapped thread key: %v", i, err) |
| 150 | } |
| 151 | if got != c.ThreadKey { |
| 152 | t.Errorf("case %d: thread key mismatch", i) |
| 153 | } |
| 154 | // And the reverse direction: Go wraps to the JS-generated public key, |
| 155 | // Go unwraps with the JS-exported pkcs8 private key. This exercises |
| 156 | // Go's parse of WebCrypto's raw + pkcs8 exports on the wrap path too. |
| 157 | rewrapped, err := WrapThreadKey(c.ThreadKey, c.PublicKey) |
| 158 | if err != nil { |
| 159 | t.Fatalf("case %d: wrap to JS pubkey: %v", i, err) |
| 160 | } |
| 161 | back, err := UnwrapThreadKey(rewrapped, c.PrivateKey) |
| 162 | if err != nil { |
| 163 | t.Fatalf("case %d: unwrap own wrap: %v", i, err) |
| 164 | } |
| 165 | if back != c.ThreadKey { |
| 166 | t.Errorf("case %d: rewrap round-trip mismatch", i) |
| 167 | } |
| 168 | } |
| 169 | } |
| 170 | |
| 171 | func TestVectorsSeals(t *testing.T) { |
| 172 | for i, c := range loadVectors(t).Seals { |
| 173 | var got json.RawMessage |
| 174 | if err := OpenSealed(c.Sealed, c.PrivateKey, &got); err != nil { |
| 175 | t.Fatalf("case %d: open JS seal: %v", i, err) |
| 176 | } |
| 177 | if !jsonEqual(t, got, c.Plaintext) { |
| 178 | t.Errorf("case %d: got %s, want %s", i, got, c.Plaintext) |
| 179 | } |
| 180 | } |
| 181 | } |
| 182 | |
| 183 | func TestRoundTripsAndWireShape(t *testing.T) { |
| 184 | key, err := GenerateKey() |
| 185 | if err != nil { |
| 186 | t.Fatal(err) |
| 187 | } |
| 188 | blob, err := EncryptJSON(key, map[string]string{"text": "round trip"}) |
| 189 | if err != nil { |
| 190 | t.Fatal(err) |
| 191 | } |
| 192 | var out map[string]string |
| 193 | if err := DecryptJSON(key, blob, &out); err != nil { |
| 194 | t.Fatal(err) |
| 195 | } |
| 196 | if out["text"] != "round trip" { |
| 197 | t.Errorf("got %q", out["text"]) |
| 198 | } |
| 199 | |
| 200 | kp, err := GenerateUserKeypair() |
| 201 | if err != nil { |
| 202 | t.Fatal(err) |
| 203 | } |
| 204 | if raw, _ := B64uDecode(kp.PublicKey); len(raw) != 65 || raw[0] != 0x04 { |
| 205 | t.Errorf("public key is not a 65-byte uncompressed P-256 point") |
| 206 | } |
| 207 | |
| 208 | threadKey := B64uEncode(key) |
| 209 | wrapped, err := WrapThreadKey(threadKey, kp.PublicKey) |
| 210 | if err != nil { |
| 211 | t.Fatal(err) |
| 212 | } |
| 213 | // The ECIES wire shape crypto.js expects: ephPub(65) || iv(12) || ct. |
| 214 | if raw, _ := B64uDecode(wrapped); len(raw) < 65+12+len(key)+16 || raw[0] != 0x04 { |
| 215 | t.Errorf("wrapped blob has wrong shape (len %d)", len(wrapped)) |
| 216 | } |
| 217 | back, err := UnwrapThreadKey(wrapped, kp.PrivateKey) |
| 218 | if err != nil { |
| 219 | t.Fatal(err) |
| 220 | } |
| 221 | if back != threadKey { |
| 222 | t.Error("thread key round trip mismatch") |
| 223 | } |
| 224 | |
| 225 | // Wrong recipient must fail, not silently succeed. |
| 226 | other, err := GenerateUserKeypair() |
| 227 | if err != nil { |
| 228 | t.Fatal(err) |
| 229 | } |
| 230 | if _, err := UnwrapThreadKey(wrapped, other.PrivateKey); err == nil { |
| 231 | t.Error("unwrap with the wrong private key succeeded") |
| 232 | } |
| 233 | |
| 234 | inv1, err := DeriveInvite("AAAAAAAAAAAAAAAAAAAAAA") // 16 zero bytes |
| 235 | if err != nil { |
| 236 | t.Fatal(err) |
| 237 | } |
| 238 | inv2, _ := DeriveInvite("AAAAAAAAAAAAAAAAAAAAAA") |
| 239 | if inv1.ID != inv2.ID || inv1.ClaimHash != inv2.ClaimHash { |
| 240 | t.Error("DeriveInvite is not deterministic") |
| 241 | } |
| 242 | } |
| 243 | |