eleven / ocho Public

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

Plain git — no account needed to clone.

Download

Download this file

1// Package e2ee is the Go mirror of web/static/crypto.js — the client-side
2// encryption every lchat member speaks. It exists so bots can be ordinary Go
3// processes instead of Node (docs/bots.md § Direction: a Go sidecar).
4//
5// The wire format is crypto.js's, byte for byte:
6//
7// - AES-256-GCM blobs: base64url( iv(12) || ciphertext ) — or the raw bytes
8// for attachments.
9// - ECIES to a pubkey: base64url( ephPub(65) || iv(12) || ciphertext ),
10// key = HKDF-SHA256(ECDH(eph, recipient), salt="", info="lchat-thread-wrap").
11// - Invites: id / wrapKey / claimSecret each HKDF-derived from the
12// link-fragment secret T with domain-separated info strings.
13//
14// Compatibility is pinned by testdata/vectors.json, generated from crypto.js
15// itself (test/gen-e2ee-vectors.mjs). Any change here or in crypto.js must keep
16// those vectors passing — they are the contract.
17package e2ee
18
19import (
20 "crypto/aes"
21 "crypto/cipher"
22 "crypto/ecdh"
23 "crypto/ecdsa"
24 "crypto/rand"
25 "crypto/sha256"
26 "crypto/x509"
27 "encoding/base64"
28 "encoding/hex"
29 "encoding/json"
30 "fmt"
31 "io"
32
33 "golang.org/x/crypto/hkdf"
34)
35
36// B64uEncode matches crypto.js bytesToB64url (unpadded base64url).
37func B64uEncode(b []byte) string { return base64.RawURLEncoding.EncodeToString(b) }
38
39// B64uDecode matches crypto.js b64urlToBytes; it tolerates padding.
40func B64uDecode(s string) ([]byte, error) {
41 for len(s)%4 != 0 {
42 s += "="
43 }
44 return base64.URLEncoding.DecodeString(s)
45}
46
47// GenerateKey returns a fresh AES-256 key (raw bytes; B64uEncode to share it).
48func GenerateKey() ([]byte, error) {
49 key := make([]byte, 32)
50 _, err := rand.Read(key)
51 return key, err
52}
53
54// ImportKey decodes a base64url AES key and checks its length.
55func ImportKey(b64 string) ([]byte, error) {
56 key, err := B64uDecode(b64)
57 if err != nil {
58 return nil, err
59 }
60 if len(key) != 32 {
61 return nil, fmt.Errorf("e2ee: AES key is %d bytes, want 32", len(key))
62 }
63 return key, nil
64}
65
66func newGCM(key []byte) (cipher.AEAD, error) {
67 block, err := aes.NewCipher(key)
68 if err != nil {
69 return nil, err
70 }
71 return cipher.NewGCM(block)
72}
73
74// EncryptBytes seals pt and returns raw iv(12)||ciphertext, like crypto.js
75// encryptBytes (attachments upload these bytes as octet-stream).
76func EncryptBytes(key, pt []byte) ([]byte, error) {
77 gcm, err := newGCM(key)
78 if err != nil {
79 return nil, err
80 }
81 iv := make([]byte, 12)
82 if _, err := rand.Read(iv); err != nil {
83 return nil, err
84 }
85 return gcm.Seal(iv, iv, pt, nil), nil
86}
87
88// DecryptBytes opens raw iv(12)||ciphertext.
89func DecryptBytes(key, data []byte) ([]byte, error) {
90 gcm, err := newGCM(key)
91 if err != nil {
92 return nil, err
93 }
94 if len(data) < 12 {
95 return nil, fmt.Errorf("e2ee: blob too short")
96 }
97 return gcm.Open(nil, data[:12], data[12:], nil)
98}
99
100// EncryptJSON is crypto.js encrypt: JSON-encode v, seal, base64url the result.
101func EncryptJSON(key []byte, v any) (string, error) {
102 pt, err := json.Marshal(v)
103 if err != nil {
104 return "", err
105 }
106 out, err := EncryptBytes(key, pt)
107 if err != nil {
108 return "", err
109 }
110 return B64uEncode(out), nil
111}
112
113// DecryptJSON is crypto.js decrypt: open a base64url blob into v.
114func DecryptJSON(key []byte, blob string, v any) error {
115 data, err := B64uDecode(blob)
116 if err != nil {
117 return err
118 }
119 pt, err := DecryptBytes(key, data)
120 if err != nil {
121 return err
122 }
123 return json.Unmarshal(pt, v)
124}
125
126// ---- invite key-wrapping (see docs/invite-redesign.md) ------------------
127
128// RandomSecret mints a fresh 128-bit invite secret T (base64url).
129func RandomSecret() (string, error) {
130 b := make([]byte, 16)
131 if _, err := rand.Read(b); err != nil {
132 return "", err
133 }
134 return B64uEncode(b), nil
135}
136
137func hkdfBits(ikm []byte, info string, n int) ([]byte, error) {
138 out := make([]byte, n)
139 // WebCrypto's empty salt and Go's nil salt both mean zeros[hashLen] (RFC 5869).
140 _, err := io.ReadFull(hkdf.New(sha256.New, ikm, nil, []byte(info)), out)
141 return out, err
142}
143
144// Invite is crypto.js deriveInvite's result: everything derives from T, each
145// use domain-separated, so the server (which stores only ID + ClaimHash) can
146// neither claim the invite nor unwrap what's wrapped under WrapKey.
147type Invite struct {
148 ID string // server lookup id
149 WrapKey []byte // AES key the group key is wrapped under
150 ClaimSecret string // presented when claiming; proves knowledge of T
151 ClaimHash string // what the server stores (lowercase hex SHA-256 of ClaimSecret)
152}
153
154// DeriveInvite mirrors crypto.js deriveInvite for the secret T (base64url).
155func DeriveInvite(tB64 string) (Invite, error) {
156 t, err := B64uDecode(tB64)
157 if err != nil {
158 return Invite{}, err
159 }
160 id, err := hkdfBits(t, "lchat-invite-id", 32)
161 if err != nil {
162 return Invite{}, err
163 }
164 wrapKey, err := hkdfBits(t, "lchat-invite-wrap", 32)
165 if err != nil {
166 return Invite{}, err
167 }
168 claim, err := hkdfBits(t, "lchat-invite-claim", 32)
169 if err != nil {
170 return Invite{}, err
171 }
172 claimSecret := B64uEncode(claim)
173 sum := sha256.Sum256([]byte(claimSecret))
174 return Invite{
175 ID: B64uEncode(id),
176 WrapKey: wrapKey,
177 ClaimSecret: claimSecret,
178 ClaimHash: hex.EncodeToString(sum[:]),
179 }, nil
180}
181
182// WrapGroupKey wraps a group key (base64url raw) under an invite's WrapKey.
183func WrapGroupKey(groupKeyB64 string, wrapKey []byte) (string, error) {
184 raw, err := B64uDecode(groupKeyB64)
185 if err != nil {
186 return "", err
187 }
188 out, err := EncryptBytes(wrapKey, raw)
189 if err != nil {
190 return "", err
191 }
192 return B64uEncode(out), nil
193}
194
195// UnwrapGroupKey recovers the group key (base64url raw).
196func UnwrapGroupKey(wrappedB64 string, wrapKey []byte) (string, error) {
197 data, err := B64uDecode(wrappedB64)
198 if err != nil {
199 return "", err
200 }
201 raw, err := DecryptBytes(wrapKey, data)
202 if err != nil {
203 return "", err
204 }
205 return B64uEncode(raw), nil
206}
207
208// ---- user keypairs + ECIES (see docs/threads-and-dms.md) ----------------
209
210// Keypair carries a user identity like the browser session does: the public
211// key as the raw uncompressed P-256 point (65 bytes), the private key as
212// PKCS#8 — both base64url, interchangeable with WebCrypto's raw/pkcs8 exports.
213type Keypair struct {
214 PublicKey string
215 PrivateKey string
216}
217
218// GenerateUserKeypair mirrors crypto.js generateUserKeypair.
219func GenerateUserKeypair() (Keypair, error) {
220 priv, err := ecdh.P256().GenerateKey(rand.Reader)
221 if err != nil {
222 return Keypair{}, err
223 }
224 pkcs8, err := x509.MarshalPKCS8PrivateKey(priv)
225 if err != nil {
226 return Keypair{}, err
227 }
228 return Keypair{
229 PublicKey: B64uEncode(priv.PublicKey().Bytes()),
230 PrivateKey: B64uEncode(pkcs8),
231 }, nil
232}
233
234func parsePub(pubB64 string) (*ecdh.PublicKey, error) {
235 raw, err := B64uDecode(pubB64)
236 if err != nil {
237 return nil, err
238 }
239 return ecdh.P256().NewPublicKey(raw)
240}
241
242func parsePriv(privB64 string) (*ecdh.PrivateKey, error) {
243 der, err := B64uDecode(privB64)
244 if err != nil {
245 return nil, err
246 }
247 key, err := x509.ParsePKCS8PrivateKey(der)
248 if err != nil {
249 return nil, err
250 }
251 switch k := key.(type) {
252 case *ecdsa.PrivateKey: // NIST-curve PKCS#8 parses as ecdsa; convert
253 return k.ECDH()
254 case *ecdh.PrivateKey:
255 return k, nil
256 default:
257 return nil, fmt.Errorf("e2ee: unsupported private key type %T", key)
258 }
259}
260
261// eciesKey mirrors crypto.js eciesKey: ECDH -> HKDF-SHA256 -> AES-256 key.
262func eciesKey(priv *ecdh.PrivateKey, pub *ecdh.PublicKey) ([]byte, error) {
263 secret, err := priv.ECDH(pub)
264 if err != nil {
265 return nil, err
266 }
267 return hkdfBits(secret, "lchat-thread-wrap", 32)
268}
269
270// sealTo produces the shared ECIES wire shape: ephPub(65) || iv(12) || ct.
271func sealTo(recipientPubB64 string, pt []byte) (string, error) {
272 recipient, err := parsePub(recipientPubB64)
273 if err != nil {
274 return "", err
275 }
276 eph, err := ecdh.P256().GenerateKey(rand.Reader)
277 if err != nil {
278 return "", err
279 }
280 aesKey, err := eciesKey(eph, recipient)
281 if err != nil {
282 return "", err
283 }
284 body, err := EncryptBytes(aesKey, pt)
285 if err != nil {
286 return "", err
287 }
288 return B64uEncode(append(eph.PublicKey().Bytes(), body...)), nil
289}
290
291func openFrom(sealedB64, privKeyB64 string) ([]byte, error) {
292 buf, err := B64uDecode(sealedB64)
293 if err != nil {
294 return nil, err
295 }
296 if len(buf) < 65+12 {
297 return nil, fmt.Errorf("e2ee: sealed blob too short")
298 }
299 ephPub, err := ecdh.P256().NewPublicKey(buf[:65])
300 if err != nil {
301 return nil, err
302 }
303 priv, err := parsePriv(privKeyB64)
304 if err != nil {
305 return nil, err
306 }
307 aesKey, err := eciesKey(priv, ephPub)
308 if err != nil {
309 return nil, err
310 }
311 return DecryptBytes(aesKey, buf[65:])
312}
313
314// WrapThreadKey wraps a thread's AES key (base64url raw) to a recipient's
315// public key, exactly like crypto.js wrapThreadKey.
316func WrapThreadKey(threadKeyB64, recipientPubB64 string) (string, error) {
317 raw, err := B64uDecode(threadKeyB64)
318 if err != nil {
319 return "", err
320 }
321 return sealTo(recipientPubB64, raw)
322}
323
324// UnwrapThreadKey recovers a thread key (base64url raw) with your private key.
325func UnwrapThreadKey(wrappedB64, privKeyB64 string) (string, error) {
326 raw, err := openFrom(wrappedB64, privKeyB64)
327 if err != nil {
328 return "", err
329 }
330 return B64uEncode(raw), nil
331}
332
333// SealForPub seals a JSON-serialisable value to a public key — wrapThreadKey's
334// sibling for capability grants (docs/connected-services.md).
335func SealForPub(recipientPubB64 string, v any) (string, error) {
336 pt, err := json.Marshal(v)
337 if err != nil {
338 return "", err
339 }
340 return sealTo(recipientPubB64, pt)
341}
342
343// OpenSealed opens a SealForPub blob into v with your private key.
344func OpenSealed(sealedB64, privKeyB64 string, v any) error {
345 pt, err := openFrom(sealedB64, privKeyB64)
346 if err != nil {
347 return err
348 }
349 return json.Unmarshal(pt, v)
350}
351