| 1 | // Sealing to a public key: the device handoff, and later the drop box. |
| 2 | // |
| 3 | // This is the one place in CONTRA where a secret travels through the server. |
| 4 | // The claim on the tin — "the server relays a blob it cannot open" — is only |
| 5 | // true because the key is derived from an ECDH secret between the two devices |
| 6 | // and never exists on the server at all. |
| 7 | // |
| 8 | // The Go twin is internal/contra/crypto/ecies.go and both are pinned by |
| 9 | // vectors/contra-v1.json ("handoff"), whose generator checks OpenSSL against |
| 10 | // RFC 5903 before it is allowed to produce a byte. |
| 11 | // |
| 12 | // envelope = ephemeral_public(65) || nonce(12) || ciphertext || tag(16) |
| 13 | // key = HKDF(ikm = X(d*P), salt = eph_pub || recipient_pub, |
| 14 | // info = "contra/v1/ecies", L = 32) |
| 15 | |
| 16 | import { concat, randomBytes } from "./bytes.js"; |
| 17 | import { hkdf } from "./kdf.js"; |
| 18 | import { publicKeyFor } from "./p256math.js"; |
| 19 | import { open, seal } from "./sealed.js"; |
| 20 | |
| 21 | export const LABEL_ECIES = "contra/v1/ecies"; |
| 22 | export const PUBLIC_KEY_LEN = 65; |
| 23 | export const NONCE_LEN = 12; |
| 24 | |
| 25 | const b64url = (b) => btoa(String.fromCharCode(...b)) |
| 26 | .replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""); |
| 27 | |
| 28 | function b64urlToBytes(s) { |
| 29 | const b64 = s.replace(/-/g, "+").replace(/_/g, "/"); |
| 30 | const raw = atob(b64.padEnd(Math.ceil(b64.length / 4) * 4, "=")); |
| 31 | const out = new Uint8Array(raw.length); |
| 32 | for (let i = 0; i < raw.length; i++) out[i] = raw.charCodeAt(i); |
| 33 | return out; |
| 34 | } |
| 35 | |
| 36 | function splitPoint(publicKey) { |
| 37 | if (publicKey.length !== PUBLIC_KEY_LEN || publicKey[0] !== 0x04) { |
| 38 | throw new Error("contra: not an uncompressed P-256 point"); |
| 39 | } |
| 40 | return { x: publicKey.slice(1, 33), y: publicKey.slice(33) }; |
| 41 | } |
| 42 | |
| 43 | async function importPublic(publicKey) { |
| 44 | return crypto.subtle.importKey( |
| 45 | "raw", publicKey, { name: "ECDH", namedCurve: "P-256" }, false, []); |
| 46 | } |
| 47 | |
| 48 | // Private ECDH keys are imported as a full JWK for the same reason p256.js |
| 49 | // does it: a PKCS#8 with the public half omitted is a `DataError` on Safari. |
| 50 | // The point is recomputed here rather than trusted from anywhere. |
| 51 | async function importPrivate(scalar) { |
| 52 | const { x, y } = publicKeyFor(scalar); |
| 53 | const key = await crypto.subtle.importKey( |
| 54 | "jwk", |
| 55 | { |
| 56 | kty: "EC", crv: "P-256", ext: true, |
| 57 | d: b64url(scalar), x: b64url(x), y: b64url(y), |
| 58 | }, |
| 59 | { name: "ECDH", namedCurve: "P-256" }, false, ["deriveBits"]); |
| 60 | return { key, publicKey: concat(new Uint8Array([0x04]), x, y) }; |
| 61 | } |
| 62 | |
| 63 | // ecdh is the raw primitive: X(d*P) at the full field width, Y discarded. |
| 64 | // WebCrypto's deriveBits for ECDH returns exactly that, which is why the |
| 65 | // vector checks it from both sides rather than trusting the name. |
| 66 | export async function ecdh(scalar, peerPublic) { |
| 67 | const { key } = await importPrivate(scalar); |
| 68 | const bits = await crypto.subtle.deriveBits( |
| 69 | { name: "ECDH", public: await importPublic(peerPublic) }, key, 256); |
| 70 | return new Uint8Array(bits); |
| 71 | } |
| 72 | |
| 73 | // eciesKey binds both public points into the salt, not just the shared |
| 74 | // secret. The secret alone names a relationship rather than a message. |
| 75 | export async function eciesKey(ephemeralPublic, recipientPublic, shared) { |
| 76 | return hkdf(shared, concat(ephemeralPublic, recipientPublic), LABEL_ECIES, 32); |
| 77 | } |
| 78 | |
| 79 | // sealTo mints a fresh ephemeral keypair per message, so the AEAD key is new |
| 80 | // even though the recipient's key is not. |
| 81 | // |
| 82 | // The AAD is the caller's, and for the handoff it names the recipient's own |
| 83 | // public key — so the server cannot relay one envelope to a second device. |
| 84 | export async function sealTo(recipientPublic, additionalData, plaintext) { |
| 85 | splitPoint(recipientPublic); |
| 86 | const pair = await crypto.subtle.generateKey( |
| 87 | { name: "ECDH", namedCurve: "P-256" }, true, ["deriveBits"]); |
| 88 | const jwk = await crypto.subtle.exportKey("jwk", pair.privateKey); |
| 89 | const ephemeralPublic = concat( |
| 90 | new Uint8Array([0x04]), b64urlToBytes(jwk.x), b64urlToBytes(jwk.y)); |
| 91 | |
| 92 | const bits = await crypto.subtle.deriveBits( |
| 93 | { name: "ECDH", public: await importPublic(recipientPublic) }, |
| 94 | pair.privateKey, 256); |
| 95 | const key = await eciesKey(ephemeralPublic, recipientPublic, new Uint8Array(bits)); |
| 96 | |
| 97 | const nonce = randomBytes(NONCE_LEN); |
| 98 | const sealed = await seal(key, nonce, additionalData, plaintext); |
| 99 | return concat(ephemeralPublic, nonce, sealed); |
| 100 | } |
| 101 | |
| 102 | // openFrom reverses sealTo. The recipient's own public key is passed rather |
| 103 | // than recomputed because it is bound into the derivation: a caller holding |
| 104 | // the wrong one should be told by a failing tag, not by a silent mismatch. |
| 105 | export async function openFrom(scalar, recipientPublic, additionalData, envelope) { |
| 106 | if (envelope.length < PUBLIC_KEY_LEN + NONCE_LEN) { |
| 107 | throw new Error("contra: handoff envelope is too short"); |
| 108 | } |
| 109 | const ephemeralPublic = envelope.slice(0, PUBLIC_KEY_LEN); |
| 110 | const nonce = envelope.slice(PUBLIC_KEY_LEN, PUBLIC_KEY_LEN + NONCE_LEN); |
| 111 | const shared = await ecdh(scalar, ephemeralPublic); |
| 112 | const key = await eciesKey(ephemeralPublic, recipientPublic, shared); |
| 113 | return open(key, nonce, additionalData, envelope.slice(PUBLIC_KEY_LEN + NONCE_LEN)); |
| 114 | } |
| 115 | |
| 116 | // newEphemeral is what the fresh device mints before it shows a code. The |
| 117 | // scalar stays in this tab and never crosses the wire; only the point does. |
| 118 | // |
| 119 | // Rejection sampling rather than clamping, for the same reason deriveP256 |
| 120 | // rejects: a uniform 32-byte string is not a uniform scalar mod n. A rejection |
| 121 | // is a ~2^-32 event, so the second pass is essentially never taken. |
| 122 | export async function newEphemeral() { |
| 123 | for (let i = 0; i < 8; i++) { |
| 124 | const scalar = randomBytes(32); |
| 125 | try { |
| 126 | return { scalar, publicKey: (await importPrivate(scalar)).publicKey }; |
| 127 | } catch { |
| 128 | // Out of [1, n-1]. Draw again. |
| 129 | } |
| 130 | } |
| 131 | throw new Error("contra: no valid P-256 scalar in 8 draws"); |
| 132 | } |
| 133 | |