mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 20:42:43 +00:00
Merge pull request #1037 from MBombeck/chore/deps-webauthn14-react
SimpleWebAuthn 14, React 19.3, CodeQL on push to main
This commit is contained in:
7 files changed
+1777
-831
No files matched your search
@@ -5,12 +5,15 @@ name: CodeQL
|
||||
# settings, so the query suite, the excluded paths and the schedule are
|
||||
# reviewable in the diff like everything else here.
|
||||
#
|
||||
# Deliberately NOT on `push: main`. The weekly run already analyses the
|
||||
# default branch, which is what keeps the Security tab current, and every
|
||||
# change reaches `main` through a pull request that is analysed on the way
|
||||
# in. Adding a push trigger would analyse the same tree a second time
|
||||
# minutes later for no additional signal.
|
||||
# Runs on every push to `main` as well as on pull requests. Most changes
|
||||
# reach `main` through a pull request that is analysed on the way in, but
|
||||
# not all of them: an asset uploaded through the web UI or a maintenance
|
||||
# commit pushed directly lands without one, and the weekly run would only
|
||||
# catch it days later. The push run re-analyses a merged tree once more,
|
||||
# which is the cost of every commit on `main` having an analysis of its own.
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
branches: [main]
|
||||
schedule:
|
||||
@@ -22,9 +25,13 @@ on:
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
# A pull request keeps only its newest run: an older head is superseded and
|
||||
# never merged. A push to `main` is grouped by commit and never cancelled, so
|
||||
# two merges in quick succession each keep their own analysis instead of the
|
||||
# second one cancelling (or replacing the queued run of) the first.
|
||||
concurrency:
|
||||
group: codeql-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
group: codeql-${{ github.event_name }}-${{ github.event_name == 'pull_request' && github.ref || github.sha }}
|
||||
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
|
||||
|
||||
jobs:
|
||||
analyze:
|
||||
|
||||
+6
-6
@@ -65,8 +65,8 @@
|
||||
"@parse/node-apn": "^8.1.0",
|
||||
"@prisma/adapter-pg": "^7.10.0",
|
||||
"@prisma/client": "^7.10.0",
|
||||
"@simplewebauthn/browser": "^13.3.0",
|
||||
"@simplewebauthn/server": "^13.3.3",
|
||||
"@simplewebauthn/browser": "^14.0.0",
|
||||
"@simplewebauthn/server": "^14.0.2",
|
||||
"@tanstack/react-query": "5.102.8",
|
||||
"@tanstack/react-virtual": "^3.14.12",
|
||||
"@types/sax": "^1.2.7",
|
||||
@@ -90,9 +90,9 @@
|
||||
"prisma": "^7.10.0",
|
||||
"qrcode": "^1.5.4",
|
||||
"radix-ui": "^1.6.7",
|
||||
"react": "19.2.7",
|
||||
"react": "19.3.0",
|
||||
"react-day-picker": "^10.0.1",
|
||||
"react-dom": "19.2.7",
|
||||
"react-dom": "19.3.0",
|
||||
"react-hook-form": "7.83.0",
|
||||
"recharts": "^3.10.1",
|
||||
"rrule": "^2.8.1",
|
||||
@@ -116,8 +116,8 @@
|
||||
"@types/node": "26.5.1",
|
||||
"@types/pg": "^8.23.1",
|
||||
"@types/qrcode": "^1.5.6",
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
"@types/react": "^19.3.0",
|
||||
"@types/react-dom": "^19.3.0",
|
||||
"@types/web-push": "^3.6.4",
|
||||
"@vitejs/plugin-react": "^6.1.1",
|
||||
"@vitest/coverage-v8": "^4.1.5",
|
||||
|
||||
Generated
+837
-818
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,892 @@
|
||||
/**
|
||||
* Wire contract between HealthLog and `@simplewebauthn/server`, exercised
|
||||
* against the real library rather than a mock.
|
||||
*
|
||||
* Every other passkey test mocks the library, so a major bump of it would
|
||||
* pass them all while changing what a browser or the iOS app receives, or
|
||||
* which credentials the server accepts. This file pins the three things a
|
||||
* library upgrade must not move:
|
||||
*
|
||||
* 1. The JSON the option endpoints return (challenge and user handle are
|
||||
* random and are masked; every other field is compared verbatim).
|
||||
* 2. The algorithm set: registration offers EdDSA / ES256 / RS256 and
|
||||
* verification accepts every algorithm the server has ever accepted, so a
|
||||
* credential that registered before an upgrade still registers after it.
|
||||
* 3. Stored credentials: a key derived from a fixed seed must produce the
|
||||
* same stored public-key bytes, and an assertion from it must verify
|
||||
* through the same code path login and step-up use.
|
||||
*
|
||||
* The authenticator is a software one: it builds `fmt: "none"` attestation
|
||||
* objects and signs assertions with Node's crypto, the way a platform
|
||||
* authenticator would.
|
||||
*/
|
||||
import {
|
||||
createECDH,
|
||||
createHash,
|
||||
createPrivateKey,
|
||||
createPublicKey,
|
||||
generateKeyPairSync,
|
||||
randomBytes,
|
||||
sign,
|
||||
constants as cryptoConstants,
|
||||
type KeyObject,
|
||||
} from "node:crypto";
|
||||
import {
|
||||
afterAll,
|
||||
beforeAll,
|
||||
beforeEach,
|
||||
describe,
|
||||
expect,
|
||||
it,
|
||||
vi,
|
||||
} from "vitest";
|
||||
import { NextRequest } from "next/server";
|
||||
import { isoCBOR } from "@simplewebauthn/server/helpers";
|
||||
import {
|
||||
generateRegistrationOptions,
|
||||
verifyRegistrationResponse,
|
||||
} from "@simplewebauthn/server";
|
||||
|
||||
// ── In-memory stand-in for the four tables the ceremonies touch ──────
|
||||
|
||||
const store = vi.hoisted(() => ({
|
||||
authChallenge: new Map<string, Record<string, unknown>>(),
|
||||
passkey: new Map<string, Record<string, unknown>>(),
|
||||
mfa: new Map<string, Record<string, unknown>>(),
|
||||
seq: 0,
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/db", () => {
|
||||
const byCredential = (
|
||||
table: Map<string, Record<string, unknown>>,
|
||||
credentialId: unknown,
|
||||
) => [...table.values()].find((r) => r.credentialId === credentialId) ?? null;
|
||||
return {
|
||||
prisma: {
|
||||
authChallenge: {
|
||||
deleteMany: async () => ({ count: 0 }),
|
||||
create: async ({ data }: { data: Record<string, unknown> }) => {
|
||||
const id = `challenge-${++store.seq}`;
|
||||
const row = { id, ...data };
|
||||
store.authChallenge.set(id, row);
|
||||
return row;
|
||||
},
|
||||
findUnique: async ({ where }: { where: { id: string } }) =>
|
||||
store.authChallenge.get(where.id) ?? null,
|
||||
delete: async ({ where }: { where: { id: string } }) => {
|
||||
store.authChallenge.delete(where.id);
|
||||
return {};
|
||||
},
|
||||
},
|
||||
passkey: {
|
||||
findMany: async ({ where }: { where: { userId: string } }) =>
|
||||
[...store.passkey.values()].filter((r) => r.userId === where.userId),
|
||||
findUnique: async ({ where }: { where: { credentialId: string } }) =>
|
||||
byCredential(store.passkey, where.credentialId),
|
||||
update: async ({
|
||||
where,
|
||||
data,
|
||||
}: {
|
||||
where: { id: string };
|
||||
data: Record<string, unknown>;
|
||||
}) => Object.assign(store.passkey.get(where.id) ?? {}, data),
|
||||
},
|
||||
webauthnMfaCredential: {
|
||||
findMany: async ({ where }: { where: { userId: string } }) =>
|
||||
[...store.mfa.values()].filter((r) => r.userId === where.userId),
|
||||
findFirst: async ({
|
||||
where,
|
||||
}: {
|
||||
where: { credentialId: string; userId: string };
|
||||
}) => {
|
||||
const row = byCredential(store.mfa, where.credentialId);
|
||||
return row && row.userId === where.userId ? row : null;
|
||||
},
|
||||
update: async ({
|
||||
where,
|
||||
data,
|
||||
}: {
|
||||
where: { id: string };
|
||||
data: Record<string, unknown>;
|
||||
}) => Object.assign(store.mfa.get(where.id) ?? {}, data),
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
// Pass-through spies: the real library runs, the test can read the arguments.
|
||||
vi.mock("@simplewebauthn/server", async (importOriginal) => {
|
||||
const real = await importOriginal<typeof import("@simplewebauthn/server")>();
|
||||
return {
|
||||
...real,
|
||||
generateRegistrationOptions: vi.fn(real.generateRegistrationOptions),
|
||||
verifyRegistrationResponse: vi.fn(real.verifyRegistrationResponse),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@/lib/rate-limit", () => ({
|
||||
checkAuthSurfaceRateLimit: vi.fn().mockResolvedValue({
|
||||
allowed: true,
|
||||
remaining: 9,
|
||||
reset: 0,
|
||||
ip: "203.0.113.1",
|
||||
}),
|
||||
rateLimitHeaders: vi.fn(() => ({})),
|
||||
}));
|
||||
|
||||
import {
|
||||
createAuthenticationOptions,
|
||||
createRegistrationOptions,
|
||||
verifyAuthentication,
|
||||
verifyRegistration,
|
||||
} from "../passkey";
|
||||
import {
|
||||
createMfaAuthenticationOptions,
|
||||
createMfaRegistrationOptions,
|
||||
verifyMfaAuthentication,
|
||||
verifyMfaRegistration,
|
||||
} from "../mfa/webauthn";
|
||||
import { POST as LOGIN_OPTIONS } from "@/app/api/auth/passkey/login-options/route";
|
||||
|
||||
const ORIGIN = "https://health.example.test";
|
||||
const RP_ID = "health.example.test";
|
||||
const USER_ID = "user-contract";
|
||||
|
||||
let savedAppUrl: string | undefined;
|
||||
let savedPublicUrl: string | undefined;
|
||||
let savedHmacKey: string | undefined;
|
||||
|
||||
beforeAll(() => {
|
||||
savedAppUrl = process.env.APP_URL;
|
||||
savedPublicUrl = process.env.NEXT_PUBLIC_APP_URL;
|
||||
process.env.APP_URL = ORIGIN;
|
||||
delete process.env.NEXT_PUBLIC_APP_URL;
|
||||
savedHmacKey = process.env.API_TOKEN_HMAC_KEY;
|
||||
process.env.API_TOKEN_HMAC_KEY ??= "contract-test-hmac-key-".padEnd(64, "x");
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
if (savedAppUrl === undefined) delete process.env.APP_URL;
|
||||
else process.env.APP_URL = savedAppUrl;
|
||||
if (savedPublicUrl === undefined) delete process.env.NEXT_PUBLIC_APP_URL;
|
||||
else process.env.NEXT_PUBLIC_APP_URL = savedPublicUrl;
|
||||
if (savedHmacKey === undefined) delete process.env.API_TOKEN_HMAC_KEY;
|
||||
else process.env.API_TOKEN_HMAC_KEY = savedHmacKey;
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
store.authChallenge.clear();
|
||||
store.passkey.clear();
|
||||
store.mfa.clear();
|
||||
store.seq = 0;
|
||||
});
|
||||
|
||||
// ── Software authenticator ───────────────────────────────────────────
|
||||
|
||||
const b64url = (buf: Uint8Array) => Buffer.from(buf).toString("base64url");
|
||||
const sha256 = (data: Uint8Array | string) =>
|
||||
createHash("sha256").update(data).digest();
|
||||
|
||||
type Alg = {
|
||||
alg: number;
|
||||
name: string;
|
||||
keyPair: () => { privateKey: KeyObject; publicKey: KeyObject };
|
||||
cose: (publicKey: KeyObject) => Map<number, number | Uint8Array>;
|
||||
sign: (privateKey: KeyObject, data: Buffer) => Buffer;
|
||||
};
|
||||
|
||||
function ec2Cose(alg: number, crv: number) {
|
||||
return (publicKey: KeyObject) => {
|
||||
const jwk = publicKey.export({ format: "jwk" });
|
||||
return new Map<number, number | Uint8Array>([
|
||||
[1, 2],
|
||||
[3, alg],
|
||||
[-1, crv],
|
||||
[-2, Buffer.from(jwk.x!, "base64url")],
|
||||
[-3, Buffer.from(jwk.y!, "base64url")],
|
||||
]);
|
||||
};
|
||||
}
|
||||
|
||||
function rsaCose(alg: number) {
|
||||
return (publicKey: KeyObject) => {
|
||||
const jwk = publicKey.export({ format: "jwk" });
|
||||
return new Map<number, number | Uint8Array>([
|
||||
[1, 3],
|
||||
[3, alg],
|
||||
[-1, Buffer.from(jwk.n!, "base64url")],
|
||||
[-2, Buffer.from(jwk.e!, "base64url")],
|
||||
]);
|
||||
};
|
||||
}
|
||||
|
||||
const rsaKeys = () => generateKeyPairSync("rsa", { modulusLength: 2048 });
|
||||
const pss = (hash: string, saltLength: number) => (k: KeyObject, d: Buffer) =>
|
||||
sign(hash, d, {
|
||||
key: k,
|
||||
padding: cryptoConstants.RSA_PKCS1_PSS_PADDING,
|
||||
saltLength,
|
||||
});
|
||||
const pkcs1 = (hash: string) => (k: KeyObject, d: Buffer) => sign(hash, d, k);
|
||||
|
||||
/** Every COSE algorithm the 13.x verifier accepted by default. */
|
||||
const LEGACY_ALGS: Alg[] = [
|
||||
{
|
||||
alg: -8,
|
||||
name: "EdDSA",
|
||||
keyPair: () => generateKeyPairSync("ed25519"),
|
||||
cose: (publicKey) => {
|
||||
const jwk = publicKey.export({ format: "jwk" });
|
||||
return new Map<number, number | Uint8Array>([
|
||||
[1, 1],
|
||||
[3, -8],
|
||||
[-1, 6],
|
||||
[-2, Buffer.from(jwk.x!, "base64url")],
|
||||
]);
|
||||
},
|
||||
sign: (k, d) => sign(null, d, k),
|
||||
},
|
||||
{
|
||||
alg: -7,
|
||||
name: "ES256",
|
||||
keyPair: () => generateKeyPairSync("ec", { namedCurve: "P-256" }),
|
||||
cose: ec2Cose(-7, 1),
|
||||
sign: (k, d) => sign("sha256", d, k),
|
||||
},
|
||||
{
|
||||
alg: -36,
|
||||
name: "ES512",
|
||||
keyPair: () => generateKeyPairSync("ec", { namedCurve: "P-521" }),
|
||||
cose: ec2Cose(-36, 3),
|
||||
sign: (k, d) => sign("sha512", d, k),
|
||||
},
|
||||
{
|
||||
alg: -37,
|
||||
name: "PS256",
|
||||
keyPair: rsaKeys,
|
||||
cose: rsaCose(-37),
|
||||
sign: pss("sha256", 32),
|
||||
},
|
||||
{
|
||||
alg: -38,
|
||||
name: "PS384",
|
||||
keyPair: rsaKeys,
|
||||
cose: rsaCose(-38),
|
||||
sign: pss("sha384", 48),
|
||||
},
|
||||
{
|
||||
alg: -39,
|
||||
name: "PS512",
|
||||
keyPair: rsaKeys,
|
||||
cose: rsaCose(-39),
|
||||
sign: pss("sha512", 64),
|
||||
},
|
||||
{
|
||||
alg: -257,
|
||||
name: "RS256",
|
||||
keyPair: rsaKeys,
|
||||
cose: rsaCose(-257),
|
||||
sign: pkcs1("sha256"),
|
||||
},
|
||||
{
|
||||
alg: -258,
|
||||
name: "RS384",
|
||||
keyPair: rsaKeys,
|
||||
cose: rsaCose(-258),
|
||||
sign: pkcs1("sha384"),
|
||||
},
|
||||
{
|
||||
alg: -259,
|
||||
name: "RS512",
|
||||
keyPair: rsaKeys,
|
||||
cose: rsaCose(-259),
|
||||
sign: pkcs1("sha512"),
|
||||
},
|
||||
{
|
||||
alg: -65535,
|
||||
name: "RS1",
|
||||
keyPair: rsaKeys,
|
||||
cose: rsaCose(-65535),
|
||||
sign: pkcs1("sha1"),
|
||||
},
|
||||
];
|
||||
|
||||
const ES256 = LEGACY_ALGS.find((a) => a.alg === -7)!;
|
||||
|
||||
function clientData(type: string, challenge: string) {
|
||||
return Buffer.from(
|
||||
JSON.stringify({ type, challenge, origin: ORIGIN, crossOrigin: false }),
|
||||
);
|
||||
}
|
||||
|
||||
function registrationResponse(
|
||||
challenge: string,
|
||||
credentialId: Buffer,
|
||||
cosePublicKey: Map<number, number | Uint8Array>,
|
||||
transports: string[],
|
||||
) {
|
||||
const coseBytes = isoCBOR.encode(cosePublicKey);
|
||||
const counter = Buffer.alloc(4);
|
||||
const idLength = Buffer.alloc(2);
|
||||
idLength.writeUInt16BE(credentialId.length);
|
||||
const authData = Buffer.concat([
|
||||
sha256(RP_ID),
|
||||
Buffer.from([0x45]), // UP | UV | AT
|
||||
counter,
|
||||
Buffer.alloc(16), // AAGUID, all zero under "none" attestation
|
||||
idLength,
|
||||
credentialId,
|
||||
Buffer.from(coseBytes),
|
||||
]);
|
||||
const attestationObject = isoCBOR.encode(
|
||||
new Map<string, unknown>([
|
||||
["fmt", "none"],
|
||||
["attStmt", new Map()],
|
||||
["authData", authData],
|
||||
]) as never,
|
||||
);
|
||||
return {
|
||||
id: b64url(credentialId),
|
||||
rawId: b64url(credentialId),
|
||||
type: "public-key",
|
||||
response: {
|
||||
clientDataJSON: b64url(clientData("webauthn.create", challenge)),
|
||||
attestationObject: b64url(attestationObject),
|
||||
transports,
|
||||
},
|
||||
clientExtensionResults: {},
|
||||
};
|
||||
}
|
||||
|
||||
function assertionResponse(
|
||||
challenge: string,
|
||||
credentialId: Buffer,
|
||||
alg: Alg,
|
||||
privateKey: KeyObject,
|
||||
signCount: number,
|
||||
) {
|
||||
const counter = Buffer.alloc(4);
|
||||
counter.writeUInt32BE(signCount);
|
||||
const authData = Buffer.concat([
|
||||
sha256(RP_ID),
|
||||
Buffer.from([0x05]), // UP | UV
|
||||
counter,
|
||||
]);
|
||||
const cdj = clientData("webauthn.get", challenge);
|
||||
const signature = alg.sign(
|
||||
privateKey,
|
||||
Buffer.concat([authData, sha256(cdj)]),
|
||||
);
|
||||
return {
|
||||
id: b64url(credentialId),
|
||||
rawId: b64url(credentialId),
|
||||
type: "public-key",
|
||||
response: {
|
||||
clientDataJSON: b64url(cdj),
|
||||
authenticatorData: b64url(authData),
|
||||
signature: b64url(signature),
|
||||
},
|
||||
clientExtensionResults: {},
|
||||
};
|
||||
}
|
||||
|
||||
/** Mask the random parts of an options object; keep everything else. */
|
||||
function normalise(options: Record<string, unknown>) {
|
||||
const out = structuredClone(options) as Record<string, unknown> & {
|
||||
user?: { id: string };
|
||||
};
|
||||
expect(out.challenge).toMatch(/^[A-Za-z0-9_-]{43}$/);
|
||||
out.challenge = "<32 random bytes, base64url>";
|
||||
if (out.user) {
|
||||
expect(out.user.id).toMatch(/^[A-Za-z0-9_-]+$/);
|
||||
out.user.id = "<random user handle, base64url>";
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Register a credential through the real verifier and store it as the route does. */
|
||||
async function registerPasskey(
|
||||
alg: Alg,
|
||||
keys: { privateKey: KeyObject; publicKey: KeyObject },
|
||||
credentialId: Buffer,
|
||||
) {
|
||||
const { options } = await createRegistrationOptions(
|
||||
USER_ID,
|
||||
"user@example.test",
|
||||
"sess-1",
|
||||
);
|
||||
const verification = await verifyRegistration(
|
||||
options.challenge,
|
||||
registrationResponse(
|
||||
options.challenge,
|
||||
credentialId,
|
||||
alg.cose(keys.publicKey),
|
||||
["internal", "hybrid"],
|
||||
),
|
||||
);
|
||||
expect(verification.verified).toBe(true);
|
||||
const info = verification.registrationInfo!;
|
||||
const id = `pk-${++store.seq}`;
|
||||
store.passkey.set(id, {
|
||||
id,
|
||||
userId: USER_ID,
|
||||
credentialId: info.credential.id,
|
||||
credentialPublicKey: Buffer.from(info.credential.publicKey),
|
||||
counter: BigInt(info.credential.counter),
|
||||
transports: ["internal", "hybrid"],
|
||||
});
|
||||
return verification;
|
||||
}
|
||||
|
||||
// ── 1. Option shapes ─────────────────────────────────────────────────
|
||||
|
||||
describe("option shapes returned to the web client and the iOS app", () => {
|
||||
it("passkey registration options", async () => {
|
||||
store.passkey.set("existing", {
|
||||
id: "existing",
|
||||
userId: USER_ID,
|
||||
credentialId: "ZXhpc3RpbmctY3JlZA",
|
||||
transports: ["internal", "hybrid"],
|
||||
});
|
||||
const { options } = await createRegistrationOptions(
|
||||
USER_ID,
|
||||
"user@example.test",
|
||||
"sess-1",
|
||||
);
|
||||
expect(normalise(options as never)).toMatchInlineSnapshot(`
|
||||
{
|
||||
"attestation": "none",
|
||||
"authenticatorSelection": {
|
||||
"requireResidentKey": false,
|
||||
"residentKey": "preferred",
|
||||
"userVerification": "required",
|
||||
},
|
||||
"challenge": "<32 random bytes, base64url>",
|
||||
"excludeCredentials": [
|
||||
{
|
||||
"id": "ZXhpc3RpbmctY3JlZA",
|
||||
"transports": [
|
||||
"internal",
|
||||
"hybrid",
|
||||
],
|
||||
"type": "public-key",
|
||||
},
|
||||
],
|
||||
"extensions": {
|
||||
"credProps": true,
|
||||
},
|
||||
"hints": [],
|
||||
"pubKeyCredParams": [
|
||||
{
|
||||
"alg": -8,
|
||||
"type": "public-key",
|
||||
},
|
||||
{
|
||||
"alg": -7,
|
||||
"type": "public-key",
|
||||
},
|
||||
{
|
||||
"alg": -257,
|
||||
"type": "public-key",
|
||||
},
|
||||
],
|
||||
"rp": {
|
||||
"id": "health.example.test",
|
||||
"name": "HealthLog",
|
||||
},
|
||||
"timeout": 60000,
|
||||
"user": {
|
||||
"displayName": "",
|
||||
"id": "<random user handle, base64url>",
|
||||
"name": "user@example.test",
|
||||
},
|
||||
}
|
||||
`);
|
||||
});
|
||||
|
||||
it("passkey authentication options, discoverable (login-options route body)", async () => {
|
||||
const res = await LOGIN_OPTIONS(
|
||||
new NextRequest(`${ORIGIN}/api/auth/passkey/login-options`, {
|
||||
method: "POST",
|
||||
}),
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as {
|
||||
data: { options: Record<string, unknown>; challengeId: string };
|
||||
error: null;
|
||||
};
|
||||
expect(Object.keys(body).sort()).toEqual(["data", "error"]);
|
||||
expect(Object.keys(body.data).sort()).toEqual(["challengeId", "options"]);
|
||||
expect(normalise(body.data.options)).toMatchInlineSnapshot(`
|
||||
{
|
||||
"challenge": "<32 random bytes, base64url>",
|
||||
"rpId": "health.example.test",
|
||||
"timeout": 60000,
|
||||
"userVerification": "required",
|
||||
}
|
||||
`);
|
||||
});
|
||||
|
||||
it("passkey authentication options scoped to a user (step-up)", async () => {
|
||||
store.passkey.set("existing", {
|
||||
id: "existing",
|
||||
userId: USER_ID,
|
||||
credentialId: "ZXhpc3RpbmctY3JlZA",
|
||||
transports: ["internal", "hybrid"],
|
||||
});
|
||||
const { options } = await createAuthenticationOptions(USER_ID);
|
||||
expect(normalise(options as never)).toMatchInlineSnapshot(`
|
||||
{
|
||||
"allowCredentials": [
|
||||
{
|
||||
"id": "ZXhpc3RpbmctY3JlZA",
|
||||
"transports": [
|
||||
"internal",
|
||||
"hybrid",
|
||||
],
|
||||
"type": "public-key",
|
||||
},
|
||||
],
|
||||
"challenge": "<32 random bytes, base64url>",
|
||||
"extensions": undefined,
|
||||
"rpId": "health.example.test",
|
||||
"timeout": 60000,
|
||||
"userVerification": "required",
|
||||
}
|
||||
`);
|
||||
});
|
||||
|
||||
it("security-key registration and authentication options", async () => {
|
||||
store.mfa.set("key", {
|
||||
id: "key",
|
||||
userId: USER_ID,
|
||||
credentialId: "c2VjdXJpdHkta2V5",
|
||||
transports: ["usb", "nfc"],
|
||||
});
|
||||
const reg = await createMfaRegistrationOptions(
|
||||
USER_ID,
|
||||
"user@example.test",
|
||||
);
|
||||
const auth = await createMfaAuthenticationOptions(USER_ID);
|
||||
expect(normalise(reg.options as never)).toMatchInlineSnapshot(`
|
||||
{
|
||||
"attestation": "none",
|
||||
"authenticatorSelection": {
|
||||
"authenticatorAttachment": "cross-platform",
|
||||
"requireResidentKey": false,
|
||||
"residentKey": "discouraged",
|
||||
"userVerification": "required",
|
||||
},
|
||||
"challenge": "<32 random bytes, base64url>",
|
||||
"excludeCredentials": [
|
||||
{
|
||||
"id": "c2VjdXJpdHkta2V5",
|
||||
"transports": [
|
||||
"usb",
|
||||
"nfc",
|
||||
],
|
||||
"type": "public-key",
|
||||
},
|
||||
],
|
||||
"extensions": {
|
||||
"credProps": true,
|
||||
},
|
||||
"hints": [],
|
||||
"pubKeyCredParams": [
|
||||
{
|
||||
"alg": -8,
|
||||
"type": "public-key",
|
||||
},
|
||||
{
|
||||
"alg": -7,
|
||||
"type": "public-key",
|
||||
},
|
||||
{
|
||||
"alg": -257,
|
||||
"type": "public-key",
|
||||
},
|
||||
],
|
||||
"rp": {
|
||||
"id": "health.example.test",
|
||||
"name": "HealthLog",
|
||||
},
|
||||
"timeout": 60000,
|
||||
"user": {
|
||||
"displayName": "",
|
||||
"id": "<random user handle, base64url>",
|
||||
"name": "user@example.test",
|
||||
},
|
||||
}
|
||||
`);
|
||||
expect(normalise(auth!.options as never)).toMatchInlineSnapshot(`
|
||||
{
|
||||
"allowCredentials": [
|
||||
{
|
||||
"id": "c2VjdXJpdHkta2V5",
|
||||
"transports": [
|
||||
"usb",
|
||||
"nfc",
|
||||
],
|
||||
"type": "public-key",
|
||||
},
|
||||
],
|
||||
"challenge": "<32 random bytes, base64url>",
|
||||
"extensions": undefined,
|
||||
"rpId": "health.example.test",
|
||||
"timeout": 60000,
|
||||
"userVerification": "required",
|
||||
}
|
||||
`);
|
||||
});
|
||||
});
|
||||
|
||||
// ── 2. Algorithm set ─────────────────────────────────────────────────
|
||||
|
||||
describe("algorithm set", () => {
|
||||
it.each(LEGACY_ALGS.map((a) => [a.name, a] as const))(
|
||||
"%s registers and then authenticates",
|
||||
async (_name, alg) => {
|
||||
const keys = alg.keyPair();
|
||||
const credentialId = randomBytes(16);
|
||||
const registration = await registerPasskey(alg, keys, credentialId);
|
||||
expect(registration.registrationInfo!.credential.id).toBe(
|
||||
b64url(credentialId),
|
||||
);
|
||||
|
||||
const { options, challengeId } = await createAuthenticationOptions();
|
||||
const { verification, passkey } = await verifyAuthentication(
|
||||
challengeId,
|
||||
assertionResponse(
|
||||
options.challenge,
|
||||
credentialId,
|
||||
alg,
|
||||
keys.privateKey,
|
||||
1,
|
||||
),
|
||||
);
|
||||
expect(verification.verified).toBe(true);
|
||||
expect(passkey.userId).toBe(USER_ID);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe("security-key algorithm set", () => {
|
||||
it.each(LEGACY_ALGS.map((a) => [a.name, a] as const))(
|
||||
"%s registers as a second factor",
|
||||
async (_name, alg) => {
|
||||
const keys = alg.keyPair();
|
||||
const credentialId = randomBytes(16);
|
||||
const { options, challengeId } = await createMfaRegistrationOptions(
|
||||
USER_ID,
|
||||
"user@example.test",
|
||||
);
|
||||
const verification = await verifyMfaRegistration(
|
||||
challengeId,
|
||||
USER_ID,
|
||||
registrationResponse(
|
||||
options.challenge,
|
||||
credentialId,
|
||||
alg.cose(keys.publicKey),
|
||||
["usb"],
|
||||
),
|
||||
);
|
||||
expect(verification.verified).toBe(true);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe("algorithm lists are passed explicitly", () => {
|
||||
// A library default can move under a runtime change (a post-quantum capable
|
||||
// runtime prepends ML-DSA-44) without any option snapshot on this runtime
|
||||
// noticing, so the arguments themselves are pinned.
|
||||
it("both ceremonies offer EdDSA, ES256, RS256 and accept the full legacy set", async () => {
|
||||
vi.mocked(generateRegistrationOptions).mockClear();
|
||||
vi.mocked(verifyRegistrationResponse).mockClear();
|
||||
const keys = ES256.keyPair();
|
||||
await registerPasskey(ES256, keys, randomBytes(16));
|
||||
const mfa = await createMfaRegistrationOptions(
|
||||
USER_ID,
|
||||
"user@example.test",
|
||||
);
|
||||
await verifyMfaRegistration(
|
||||
mfa.challengeId,
|
||||
USER_ID,
|
||||
registrationResponse(
|
||||
mfa.options.challenge,
|
||||
randomBytes(16),
|
||||
ES256.cose(keys.publicKey),
|
||||
["usb"],
|
||||
),
|
||||
);
|
||||
|
||||
const offered = vi
|
||||
.mocked(generateRegistrationOptions)
|
||||
.mock.calls.map(([opts]) => opts.supportedAlgorithmIDs);
|
||||
const accepted = vi
|
||||
.mocked(verifyRegistrationResponse)
|
||||
.mock.calls.map(([opts]) => opts.supportedAlgorithmIDs);
|
||||
expect(offered).toEqual([
|
||||
[-8, -7, -257],
|
||||
[-8, -7, -257],
|
||||
]);
|
||||
const legacy = LEGACY_ALGS.map((a) => a.alg);
|
||||
expect(accepted).toEqual([legacy, legacy]);
|
||||
});
|
||||
});
|
||||
|
||||
// ── 3. Stored credentials ────────────────────────────────────────────
|
||||
|
||||
/** A P-256 key derived from a fixed seed, so its stored bytes are stable. */
|
||||
function fixedEs256Key() {
|
||||
const d = sha256("healthlog-webauthn-contract-fixture");
|
||||
const ecdh = createECDH("prime256v1");
|
||||
ecdh.setPrivateKey(d);
|
||||
const pub = ecdh.getPublicKey(); // 0x04 | x | y
|
||||
const jwk = {
|
||||
kty: "EC",
|
||||
crv: "P-256",
|
||||
d: d.toString("base64url"),
|
||||
x: pub.subarray(1, 33).toString("base64url"),
|
||||
y: pub.subarray(33, 65).toString("base64url"),
|
||||
};
|
||||
const privateKey = createPrivateKey({ key: jwk, format: "jwk" });
|
||||
const { d: _d, ...publicJwk } = jwk;
|
||||
const publicKey = createPublicKey({ key: publicJwk, format: "jwk" });
|
||||
return { privateKey, publicKey };
|
||||
}
|
||||
|
||||
describe("stored credentials", () => {
|
||||
it("stores the same public-key bytes and verification fields for a fixed key", async () => {
|
||||
const keys = fixedEs256Key();
|
||||
const credentialId = Buffer.from("fixed-credential-id");
|
||||
const verification = await registerPasskey(ES256, keys, credentialId);
|
||||
const info = verification.registrationInfo!;
|
||||
expect({
|
||||
verified: verification.verified,
|
||||
credentialId: info.credential.id,
|
||||
publicKeyHex: Buffer.from(info.credential.publicKey).toString("hex"),
|
||||
counter: info.credential.counter,
|
||||
transports: info.credential.transports,
|
||||
fmt: info.fmt,
|
||||
aaguid: info.aaguid,
|
||||
credentialType: info.credentialType,
|
||||
credentialDeviceType: info.credentialDeviceType,
|
||||
credentialBackedUp: info.credentialBackedUp,
|
||||
userVerified: info.userVerified,
|
||||
origin: info.origin,
|
||||
rpID: info.rpID,
|
||||
registrationInfoKeys: Object.keys(info).sort(),
|
||||
}).toMatchInlineSnapshot(`
|
||||
{
|
||||
"aaguid": "00000000-0000-0000-0000-000000000000",
|
||||
"counter": 0,
|
||||
"credentialBackedUp": false,
|
||||
"credentialDeviceType": "singleDevice",
|
||||
"credentialId": "Zml4ZWQtY3JlZGVudGlhbC1pZA",
|
||||
"credentialType": "public-key",
|
||||
"fmt": "none",
|
||||
"origin": "https://health.example.test",
|
||||
"publicKeyHex": "a50102032620012158202a40dff8de2ee1ba264ae10e05136e3933839a3bf5a24fb40672a2fb90bccf722258205508587675cda220cac807aa2bb93596dc3ce76590eb728fab4d00dbda17d4b4",
|
||||
"registrationInfoKeys": [
|
||||
"aaguid",
|
||||
"attestationObject",
|
||||
"authenticatorExtensionResults",
|
||||
"credential",
|
||||
"credentialBackedUp",
|
||||
"credentialDeviceType",
|
||||
"credentialType",
|
||||
"fmt",
|
||||
"origin",
|
||||
"rpID",
|
||||
"userVerified",
|
||||
],
|
||||
"rpID": "health.example.test",
|
||||
"transports": [
|
||||
"internal",
|
||||
"hybrid",
|
||||
],
|
||||
"userVerified": true,
|
||||
"verified": true,
|
||||
}
|
||||
`);
|
||||
});
|
||||
|
||||
it("verifies an assertion against a credential stored before the upgrade", async () => {
|
||||
const keys = fixedEs256Key();
|
||||
const credentialId = Buffer.from("fixed-credential-id");
|
||||
const cose = isoCBOR.encode(ES256.cose(keys.publicKey));
|
||||
// The row exactly as an earlier release wrote it: raw COSE bytes, a
|
||||
// non-zero counter and the transports the browser reported.
|
||||
store.passkey.set("legacy", {
|
||||
id: "legacy",
|
||||
userId: USER_ID,
|
||||
credentialId: b64url(credentialId),
|
||||
credentialPublicKey: Buffer.from(cose),
|
||||
counter: BigInt(7),
|
||||
transports: ["internal", "hybrid"],
|
||||
});
|
||||
|
||||
const { options, challengeId } = await createAuthenticationOptions(USER_ID);
|
||||
const { verification } = await verifyAuthentication(
|
||||
challengeId,
|
||||
assertionResponse(
|
||||
options.challenge,
|
||||
credentialId,
|
||||
ES256,
|
||||
keys.privateKey,
|
||||
8,
|
||||
),
|
||||
);
|
||||
expect({
|
||||
verified: verification.verified,
|
||||
authenticationInfo: {
|
||||
...verification.authenticationInfo,
|
||||
credentialID: verification.authenticationInfo.credentialID,
|
||||
},
|
||||
}).toMatchInlineSnapshot(`
|
||||
{
|
||||
"authenticationInfo": {
|
||||
"authenticatorExtensionResults": undefined,
|
||||
"credentialBackedUp": false,
|
||||
"credentialDeviceType": "singleDevice",
|
||||
"credentialID": "Zml4ZWQtY3JlZGVudGlhbC1pZA",
|
||||
"newCounter": 8,
|
||||
"origin": "https://health.example.test",
|
||||
"rpID": "health.example.test",
|
||||
"userVerified": true,
|
||||
},
|
||||
"verified": true,
|
||||
}
|
||||
`);
|
||||
expect(store.passkey.get("legacy")!.counter).toBe(BigInt(8));
|
||||
});
|
||||
|
||||
it("verifies a security-key assertion against a stored second-factor credential", async () => {
|
||||
const keys = fixedEs256Key();
|
||||
const credentialId = Buffer.from("fixed-security-key");
|
||||
store.mfa.set("legacy-key", {
|
||||
id: "legacy-key",
|
||||
userId: USER_ID,
|
||||
credentialId: b64url(credentialId),
|
||||
credentialPublicKey: Buffer.from(
|
||||
isoCBOR.encode(ES256.cose(keys.publicKey)),
|
||||
),
|
||||
counter: BigInt(0),
|
||||
transports: ["usb"],
|
||||
});
|
||||
const { options, challengeId } =
|
||||
(await createMfaAuthenticationOptions(USER_ID))!;
|
||||
const ok = await verifyMfaAuthentication(
|
||||
challengeId,
|
||||
USER_ID,
|
||||
assertionResponse(
|
||||
options.challenge,
|
||||
credentialId,
|
||||
ES256,
|
||||
keys.privateKey,
|
||||
1,
|
||||
),
|
||||
);
|
||||
expect(ok).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -35,6 +35,8 @@ import {
|
||||
RP_NAME as rpName,
|
||||
getRpId,
|
||||
getExpectedOrigin,
|
||||
REGISTRATION_ACCEPTED_ALGORITHM_IDS,
|
||||
REGISTRATION_OFFERED_ALGORITHM_IDS,
|
||||
} from "@/lib/auth/webauthn-rp";
|
||||
|
||||
// Boundary narrowing mirrors `passkey.ts`: a malformed body fails fast with a
|
||||
@@ -107,6 +109,7 @@ export async function createMfaRegistrationOptions(
|
||||
rpID: getRpId(),
|
||||
userName: username,
|
||||
attestationType: "none",
|
||||
supportedAlgorithmIDs: REGISTRATION_OFFERED_ALGORITHM_IDS,
|
||||
excludeCredentials: existing.map((c) => ({
|
||||
id: c.credentialId,
|
||||
transports: c.transports as Transport[],
|
||||
@@ -168,6 +171,7 @@ export async function verifyMfaRegistration(
|
||||
expectedChallenge: challenge.challenge,
|
||||
expectedOrigin: getExpectedOrigin(),
|
||||
expectedRPID: getRpId(),
|
||||
supportedAlgorithmIDs: REGISTRATION_ACCEPTED_ALGORITHM_IDS,
|
||||
});
|
||||
} finally {
|
||||
await prisma.authChallenge
|
||||
|
||||
@@ -14,6 +14,8 @@ import {
|
||||
RP_NAME as rpName,
|
||||
getRpId,
|
||||
getExpectedOrigin,
|
||||
REGISTRATION_ACCEPTED_ALGORITHM_IDS,
|
||||
REGISTRATION_OFFERED_ALGORITHM_IDS,
|
||||
} from "@/lib/auth/webauthn-rp";
|
||||
import { hashToken } from "@/lib/auth/hmac";
|
||||
|
||||
@@ -111,6 +113,7 @@ export async function createRegistrationOptions(
|
||||
rpID: getRpId(),
|
||||
userName: username,
|
||||
attestationType: "none",
|
||||
supportedAlgorithmIDs: REGISTRATION_OFFERED_ALGORITHM_IDS,
|
||||
excludeCredentials: existingPasskeys.map((pk) => ({
|
||||
id: pk.credentialId,
|
||||
transports: pk.transports as Transport[],
|
||||
@@ -217,6 +220,7 @@ export async function verifyRegistration(
|
||||
expectedChallenge,
|
||||
expectedOrigin: getExpectedOrigin(),
|
||||
expectedRPID: getRpId(),
|
||||
supportedAlgorithmIDs: REGISTRATION_ACCEPTED_ALGORITHM_IDS,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -58,3 +58,23 @@ export function getExpectedOrigin(): string | string[] {
|
||||
const origins = getConfiguredOrigins();
|
||||
return origins.length === 1 ? origins[0] : origins;
|
||||
}
|
||||
|
||||
/**
|
||||
* COSE algorithms offered to the authenticator at registration, in order of
|
||||
* preference: EdDSA, ES256, RS256. Passed explicitly so the options a browser
|
||||
* receives do not follow a library default; a runtime with post-quantum
|
||||
* support would otherwise put ML-DSA-44 at the front of the list.
|
||||
*/
|
||||
export const REGISTRATION_OFFERED_ALGORITHM_IDS = [-8, -7, -257];
|
||||
|
||||
/**
|
||||
* COSE algorithms a registration response may carry. Wider than the offered
|
||||
* list on purpose: it is the set the server has always accepted (EdDSA,
|
||||
* ES256, ES512, PS256/384/512, RS256/384/512 and legacy RS1), so an
|
||||
* authenticator that picks an algorithm it was not offered keeps registering
|
||||
* as it did before. Narrowing it would turn away authenticators that work
|
||||
* today.
|
||||
*/
|
||||
export const REGISTRATION_ACCEPTED_ALGORITHM_IDS = [
|
||||
-8, -7, -36, -37, -38, -39, -257, -258, -259, -65535,
|
||||
];
|
||||
Reference in new issue
Block a user