carlos / contra Public

Clone
git clone https://amadan.net/carlos/contra

Plain git — no account needed to clone.

Download

Download this file

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
22import { concat } from "./bytes.js";
23import { hkdf, LABEL_EC_P256 } from "./kdf.js";
24import { 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.
29function 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
35const b64url = (b) => btoa(String.fromCharCode(...b))
36 .replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
37
38function 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.
50export 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.
83export 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.
96export 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
102export 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