eleven / messenger Public

Clone
git clone https://amadan.net/eleven/messenger

Plain git — no account needed to clone.

Download

Download this file

1package e2ee
2
3import (
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.
14type 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
47func 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.
66func 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
78func 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
94func 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.
120func 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
145func 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
171func 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
183func 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