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
| M | Dockerfile | 6 | ++++-- |
| M | server/ | 8 | +++++--- |
| M | test/ | 27 | ++++++++++++++++++--------- |
| M | test/ | 7 | +++++++ |
| A | test/ | 24 | ++++++++++++++++++++++++ |
@@ -17,8 +17,10 @@ RUN CGO_ENABLED=0 GOBIN=/out go install github.com/rclone/rclone@v1.75.2
1717 FROM golang:1.27.2 AS litestream1818 RUN CGO_ENABLED=0 GOBIN=/out go install github.com/benbjohnson/litestream/cmd/litestream@v0.5.171919 20-# Node 22 runs the TypeScript sources directly (type stripping).21-FROM node:22-trixie-slim20+# Node runs the TypeScript sources directly (type stripping). 24 is the LTS21+# whose WebCrypto has ML-DSA (24.7 and later), so passkeys can be22+# post-quantum; the forge itself needs 22.18 or later.23+FROM node:24-trixie-slim2224 2325 RUN apt-get update \2426 && apt-get install -y --no-install-recommends git openssh-server caddy ca-certificates tini fcgiwrap restic \@@ -22,11 +22,13 @@ export DEBIAN_FRONTEND=noninteractive
2222 apt-get update -q2323 # restic and rclone: forge backup and forge mirror (README.md, "Backups and moving hosts").2424 apt-get install -y -q --no-install-recommends git openssh-server caddy ca-certificates fcgiwrap restic rclone25-# The forge runs its TypeScript directly, which takes Node 22.18 or newer.26-# The distro's Node is used if it is new enough; otherwise install one first.25+# The forge runs its TypeScript directly, which takes Node 22.18 or newer;26+# 24 LTS (24.7 or later) also lets passkeys be post-quantum (ML-DSA-44), as27+# in the container. The distro's Node is used if it is new enough;28+# otherwise install one first.2729 ts_node() { node -e 'process.exit(process.features.typescript ? 0 : 1)' 2>/dev/null; }2830 ts_node || apt-get install -y -q --no-install-recommends nodejs npm29-ts_node || { echo "provision: forge needs Node 22.18 or newer, found $(node --version 2>/dev/null || echo none); install it and re-run" >&2; exit 1; }31+ts_node || { echo "provision: forge needs Node 22.18 or newer (24 LTS is best), found $(node --version 2>/dev/null || echo none); install it and re-run" >&2; exit 1; }3032 3133 id git >/dev/null 2>&1 || adduser --system --group --shell /bin/sh --home "$root" git3234 mkdir -p "$root"@@ -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 },@@ -36,6 +36,13 @@ T=$(CDPATH= cd -- "$T" && pwd -P)
3636 # Processes a scenario starts in the background go in T_PIDS, to be stopped.3737 T_PIDS=3838 trap 'st=$?; [ -z "$T_PIDS" ] || kill $T_PIDS 2>/dev/null || true; [ -n "${FORGE_TEST_KEEP:-}" ] && echo "kept $T" >&2 || rm -rf "$T"; exit $st' EXIT39+# FORGE_TEST_NODE: another node binary to run everything with, e.g. a newer40+# one like the container's.41+if [ -n "${FORGE_TEST_NODE:-}" ]; then42+ mkdir -p "$T/node"43+ ln -s "$FORGE_TEST_NODE" "$T/node/node"44+ PATH=$T/node:$PATH45+fi3946 4047 export FORGE_ROOT=$T/root4148 export FORGE_WWW=$FORGE_ROOT/.forge/www@@ -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"