| 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. |
| 17 | package e2ee |
| 18 | |
| 19 | import ( |
| 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). |
| 37 | func B64uEncode(b []byte) string { return base64.RawURLEncoding.EncodeToString(b) } |
| 38 | |
| 39 | // B64uDecode matches crypto.js b64urlToBytes; it tolerates padding. |
| 40 | func 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). |
| 48 | func 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. |
| 55 | func 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 | |
| 66 | func 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). |
| 76 | func 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. |
| 89 | func 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. |
| 101 | func 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. |
| 114 | func 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). |
| 129 | func 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 | |
| 137 | func 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. |
| 147 | type 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). |
| 155 | func 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. |
| 183 | func 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). |
| 196 | func 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. |
| 213 | type Keypair struct { |
| 214 | PublicKey string |
| 215 | PrivateKey string |
| 216 | } |
| 217 | |
| 218 | // GenerateUserKeypair mirrors crypto.js generateUserKeypair. |
| 219 | func 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 | |
| 234 | func 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 | |
| 242 | func 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. |
| 262 | func 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. |
| 271 | func 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 | |
| 291 | func 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. |
| 316 | func 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. |
| 325 | func 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). |
| 335 | func 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. |
| 344 | func 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 | |