clone

a personal forge on plain git: static pages, stacked-diff reviews, signed local ci, repo sites and releases

Run the container on Node 24 LTS

cat committed f4a7674
Node 24.7 and later have ML-DSA in WebCrypto, and @simplewebauthn/server
then offers ML-DSA-44 first, so passkeys made on the forge can be
post-quantum. The image moves to node:24-trixie-slim; everything still
runs on 22.18 and later, which provision.sh still accepts, saying 24 is
best.

The software authenticator can make an ML-DSA-44 key (--alg ml-dsa-44),
and identity-passkey-pqc adds and signs in with one where the runtime
has ML-DSA, checking the 1.3K public key is kept whole; elsewhere it is
skipped. FORGE_TEST_NODE runs the scenarios with another node binary,
which is how they were run on a newer Node here.
commit
f4a7674039d42a19a70c6460ac2816fbbddcd339
parent
c284d8ddfd1aef6b83b75d7a4b60ea73badee9e3
author
cat <admin@dev.site>
kind
feat risk score 1.6 of 3, Jev's estimate
5 files changed, +58 -14
MDockerfile6++++--
Mserver/provision.sh8+++++---
Mtest/authenticator.ts27++++++++++++++++++---------
Mtest/lib.sh7+++++++
Atest/scenarios/identity-passkey-pqc.sh24++++++++++++++++++++++++
M Dockerfile +4 -2
M server/provision.sh +5 -3
M test/authenticator.ts +18 -9
@@ -1,7 +1,8 @@
11 // A software authenticator for scenarios: what a browser and a passkey do2-// together, over HTTP, against lib/private-server.ts. It makes an ES2563-// key, attests with "none", keeps the key in a JSON file, and signs in with4-// it the way a discoverable credential does (the user handle comes along).2+// together, over HTTP, against lib/private-server.ts. It makes an ES256 key3+// (or, with --alg ml-dsa-44, a post-quantum one, on Node 24.7 and later),4+// attests with "none", keeps the key in a JSON file, and signs in with it5+// the way a discoverable credential does (the user handle comes along).56 // What it prints on success is the session cookie's value.67 //78 // node test/authenticator.ts enroll <server> <code> <key.json>
@@ -15,12 +16,14 @@
1516 // --no-uv no user verification1617 // --counter <n> the signature counter to send (default: one more than last time)1718 // --replay send the same signed response a second time (prints the second answer)19+// --alg ml-dsa-44 enroll an ML-DSA-44 key instead of ES2561820 import fs from 'node:fs';19-import { createHash, createPrivateKey, generateKeyPairSync, randomBytes, sign } from 'node:crypto';21+import { createHash, createPrivateKey, generateKeyPairSync, randomBytes, sign, type KeyObject } from 'node:crypto';2022 import { isoCBOR } from '@simplewebauthn/server/helpers';2123 2224 interface Key {2325 id: string;26+ alg?: string;2427 privateKey: string;2528 userHandle: string | null;2629 counter: number;
@@ -44,6 +47,7 @@ const rpOverride = flag('--rp');
4447 const counterArg = flag('--counter');4548 const noUv = has('--no-uv');4649 const replay = has('--replay');50+const alg = flag('--alg') ?? 'es256';4751 const [mode, server, ...rest] = args;4852 4953 const b64 = (b: Uint8Array): string => Buffer.from(b).toString('base64url');
@@ -85,9 +89,13 @@ if (mode === 'enroll' && rest.length === 2) {
8589 const opts = await call('/login/enroll/options', { code });8690 if (opts.status !== 200) done(opts);8791 const o = opts.data as { challenge: string; rp: { id: string }; user: { id: string } };88- const { privateKey, publicKey } = generateKeyPairSync('ec', { namedCurve: 'P-256' });89- const jwk = publicKey.export({ format: 'jwk' });90- const cose = isoCBOR.encode(new Map<number, number | Uint8Array>([[1, 2], [3, -7], [-1, 1], [-2, Buffer.from(jwk.x!, 'base64url')], [-3, Buffer.from(jwk.y!, 'base64url')]]));92+ // COSE keys: EC2 P-256 for ES256 (-7), AKP for ML-DSA-44 (-48).93+ const keyPair = generateKeyPairSync as (type: string, opts?: object) => { privateKey: KeyObject; publicKey: KeyObject };94+ const { privateKey, publicKey } = alg === 'ml-dsa-44' ? keyPair('ml-dsa-44') : keyPair('ec', { namedCurve: 'P-256' });95+ const jwk = publicKey.export({ format: 'jwk' }) as { x?: string; y?: string; pub?: string };96+ const cose = isoCBOR.encode(alg === 'ml-dsa-44'97+ ? new Map<number, number | Uint8Array>([[1, 7], [3, -48], [-1, Buffer.from(jwk.pub!, 'base64url')]])98+ : new Map<number, number | Uint8Array>([[1, 2], [3, -7], [-1, 1], [-2, Buffer.from(jwk.x!, 'base64url')], [-3, Buffer.from(jwk.y!, 'base64url')]]));9199 const id = randomBytes(16);92100 const len = Buffer.alloc(2);93101 len.writeUInt16BE(id.length);
@@ -100,7 +108,7 @@ if (mode === 'enroll' && rest.length === 2) {
100108 };101109 const r = await call('/login/enroll/verify', { code, response });102110 if (r.status === 200) {103- const key: Key = { id: b64(id), privateKey: privateKey.export({ format: 'pem', type: 'pkcs8' }) as string, userHandle: o.user.id, counter: 0 };111+ const key: Key = { id: b64(id), alg, privateKey: privateKey.export({ format: 'pem', type: 'pkcs8' }) as string, userHandle: o.user.id, counter: 0 };104112 fs.writeFileSync(file, JSON.stringify(key, null, 1) + '\n');105113 }106114 if (replay) done(await call('/login/enroll/verify', { code, response }));
@@ -114,7 +122,8 @@ if (mode === 'enroll' && rest.length === 2) {
114122 const counter = counterArg != null ? Number(counterArg) : key.counter + 1;115123 const authData = Buffer.concat([sha256(rpOverride ?? o.rpId), Buffer.from([flags(false)]), u32(counter)]);116124 const clientDataJSON = Buffer.from(JSON.stringify({ type: 'webauthn.get', challenge: o.challenge, origin: clientOrigin, crossOrigin: false }));117- const signature = sign('sha256', Buffer.concat([authData, sha256(clientDataJSON)]), createPrivateKey(key.privateKey));125+ // ES256 signs a SHA-256 of the data (DER); ML-DSA signs the data itself.126+ const signature = sign(key.alg === 'ml-dsa-44' ? null : 'sha256', Buffer.concat([authData, sha256(clientDataJSON)]), createPrivateKey(key.privateKey));118127 const response = {119128 id: key.id, rawId: key.id, type: 'public-key', authenticatorAttachment: 'platform', clientExtensionResults: {},120129 response: { clientDataJSON: b64(clientDataJSON), authenticatorData: b64(authData), signature: b64(signature), userHandle: key.userHandle ?? undefined },
M test/lib.sh +7 -0
A test/scenarios/identity-passkey-pqc.sh +24 -0
@@ -0,0 +1,24 @@
1+# On a runtime with ML-DSA (Node 24.7 and later, as in the container), the2+# forge offers ML-DSA-44 first, and a post-quantum passkey (a 1312-byte3+# public key) is added and signs in like any other. Elsewhere, skipped.4+. "$(dirname "$0")/../lib.sh"5+node -e 'require("crypto").generateKeyPairSync("ml-dsa-44")' 2>/dev/null || { note "node $(node --version) has no ML-DSA: skipped"; exit 0; }6+user dami7+repo --private dami/secret8+clone dami dami/secret "$T/w/dami"9+at "$T/w/dami" commit "first"10+ok push_as dami "$T/w/dami" origin main11+build12+export FORGE_BASE_URL=https://forge.test13+private_server14+code=$(ssh_as dami passkey quantum | sed -n 's/^ .*#enroll=//p')15+curl -s -X POST -H 'Origin: https://forge.test' -d "{\"code\":\"$code\"}" "$PRIV/login/enroll/options" >"$T/options"16+has "$T/options" '"pubKeyCredParams":\[\{"alg":-48,'17+c=$(node "$T_HOME/test/authenticator.ts" enroll "$PRIV" "$code" "$T/dami.key" --alg ml-dsa-44) || fail "enroll"18+eq "$(curl -s -o /dev/null -w '%{http_code}' -H "Cookie: __Host-forge=$c" "$PRIV/dami/secret/")" 20019+c=$(node "$T_HOME/test/authenticator.ts" login "$PRIV" "$T/dami.key") || fail "login"20+eq "$(curl -s -o /dev/null -w '%{http_code}' -H "Cookie: __Host-forge=$c" "$PRIV/dami/secret/")" 20021+n=$(node -e '22+const { DatabaseSync } = require("node:sqlite");23+console.log(new DatabaseSync(process.argv[1], { readOnly: true }).prepare("SELECT length(public_key) AS n FROM passkeys").get().n)' "$FORGE_ROOT/.forge/identity.db" 2>/dev/null)24+[ "$n" -gt 1312 ] || fail "the stored ML-DSA-44 key is $n bytes"