| 1 | // The deterministic P-256 login keypair, and the signed challenge. |
| 2 | // |
| 3 | // Sign-in is a signature, not a verifier: the client derives this keypair from |
| 4 | // the login branch and the server stores only the public half. A fully |
| 5 | // compromised server therefore holds something that cannot decrypt, cannot be |
| 6 | // replayed to impersonate, and adds no offline attack surface it did not |
| 7 | // already have via the wraps. |
| 8 | // |
| 9 | // # Why the key is imported as a JWK |
| 10 | // |
| 11 | // There is no "import a private scalar" door in WebCrypto. The obvious trick |
| 12 | // is a 67-byte PKCS#8 with the optional public half left out, which Chromium, |
| 13 | // node and recent WebKit all accept by recomputing the point — and which |
| 14 | // Safari rejects with a bare `DataError`, because the field is optional in |
| 15 | // the ASN.1 and mandatory in practice to whoever wrote that importer. |
| 16 | // |
| 17 | // So the point is computed here (p256math.js) and the key imported as a JWK |
| 18 | // carrying d, x and y, which every engine accepts. That cost about 150 lines |
| 19 | // of arithmetic and bought a signup that works in the browser this product |
| 20 | // can least afford to be wrong about. |
| 21 | |
| 22 | import { concat } from "./bytes.js"; |
| 23 | import { hkdf, LABEL_EC_P256 } from "./kdf.js"; |
| 24 | import { N, publicKeyFor } from "./p256math.js"; |
| 25 | |
| 26 | // inRange is the rejection test: a candidate is usable only when |
| 27 | // 1 <= d <= n-1. A uniform 32-byte string is not a uniform scalar mod n, and |
| 28 | // clamping instead of rejecting would bias the key. |
| 29 | function inRange(c) { |
| 30 | let d = 0n; |
| 31 | for (const b of c) d = (d << 8n) | BigInt(b); |
| 32 | return d > 0n && d < N; |
| 33 | } |
| 34 | |
| 35 | const b64url = (b) => btoa(String.fromCharCode(...b)) |
| 36 | .replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""); |
| 37 | |
| 38 | function b64urlToBytes(s) { |
| 39 | const b64 = s.replace(/-/g, "+").replace(/_/g, "/"); |
| 40 | const raw = atob(b64.padEnd(Math.ceil(b64.length / 4) * 4, "=")); |
| 41 | const out = new Uint8Array(raw.length); |
| 42 | for (let i = 0; i < raw.length; i++) out[i] = raw.charCodeAt(i); |
| 43 | return out; |
| 44 | } |
| 45 | |
| 46 | // deriveP256 turns a 32-byte seed into the keypair, rejection-sampling exactly |
| 47 | // as the Go and Swift sides do. A rejection is a ~2^-32 event, so the loop |
| 48 | // past i=0 is only ever reached by the searched seed in the vector file — |
| 49 | // which is why that seed exists. |
| 50 | export async function deriveP256(seed) { |
| 51 | for (let i = 0; i < 256; i++) { |
| 52 | const info = new Uint8Array(LABEL_EC_P256.length + 1); |
| 53 | for (let k = 0; k < LABEL_EC_P256.length; k++) info[k] = LABEL_EC_P256.charCodeAt(k); |
| 54 | info[LABEL_EC_P256.length] = i; |
| 55 | |
| 56 | const candidate = await hkdf(seed, null, info, 32); |
| 57 | if (!inRange(candidate)) continue; |
| 58 | |
| 59 | const { x, y } = publicKeyFor(candidate); |
| 60 | const privateKey = await crypto.subtle.importKey( |
| 61 | "jwk", |
| 62 | { |
| 63 | kty: "EC", crv: "P-256", ext: true, |
| 64 | d: b64url(candidate), x: b64url(x), y: b64url(y), |
| 65 | }, |
| 66 | { name: "ECDSA", namedCurve: "P-256" }, |
| 67 | false, |
| 68 | ["sign"], |
| 69 | ); |
| 70 | return { |
| 71 | scalar: candidate, |
| 72 | privateKey, |
| 73 | publicKey: concat(new Uint8Array([0x04]), x, y), |
| 74 | candidatesTried: i + 1, |
| 75 | }; |
| 76 | } |
| 77 | throw new Error("contra: no valid P-256 scalar in 256 candidates"); |
| 78 | } |
| 79 | |
| 80 | // newP256 generates a fresh keypair and returns the raw scalar alongside the |
| 81 | // SEC1 public point — for d_acct and for a vault's drop box, both of which are |
| 82 | // random rather than derived. |
| 83 | export async function newP256() { |
| 84 | const pair = await crypto.subtle.generateKey( |
| 85 | { name: "ECDSA", namedCurve: "P-256" }, true, ["sign", "verify"]); |
| 86 | const jwk = await crypto.subtle.exportKey("jwk", pair.privateKey); |
| 87 | return { |
| 88 | scalar: b64urlToBytes(jwk.d), |
| 89 | publicKey: concat(new Uint8Array([0x04]), b64urlToBytes(jwk.x), b64urlToBytes(jwk.y)), |
| 90 | }; |
| 91 | } |
| 92 | |
| 93 | // signChallenge produces the ECDSA-SHA-256 signature the server verifies |
| 94 | // against the stored public key. WebCrypto emits the raw r||s form, which is |
| 95 | // what the Go side expects — not the ASN.1 wrapper. |
| 96 | export async function signChallenge(privateKey, challenge) { |
| 97 | const sig = await crypto.subtle.sign( |
| 98 | { name: "ECDSA", hash: "SHA-256" }, privateKey, challenge); |
| 99 | return new Uint8Array(sig); |
| 100 | } |
| 101 | |
| 102 | export async function verifyChallenge(publicKey, challenge, signature) { |
| 103 | const key = await crypto.subtle.importKey( |
| 104 | "raw", publicKey, { name: "ECDSA", namedCurve: "P-256" }, false, ["verify"]); |
| 105 | return crypto.subtle.verify( |
| 106 | { name: "ECDSA", hash: "SHA-256" }, key, signature, challenge); |
| 107 | } |
| 108 | |