chadtmiller.com/pds.js
An atproto PDS in JavaScript, for Cloudflare Workers and Node
0
0
// WebAuthn ceremony checks, driven by a synthetic authenticator: a P-256 key
// pair plus the byte layouts a real one would produce.
import { base64UrlEncode } from '@pdsjs/core/crypto';
import {
COSE_ES256,
checkClientData,
derToRawEcdsaSignature,
isSupportedAlgorithm,
readSignCount,
verifyAssertion,
verifyRegistration,
} from '@pdsjs/core/webauthn';
import { describe, expect, test } from 'vitest';
const ORIGIN = 'https://pds.example.com';
const RP_ID = 'pds.example.com';
const CHALLENGE = 'Y2hhbGxlbmdlLWZvci10ZXN0aW5n';
/** @param {object} data */
function encodeClientData(data) {
return base64UrlEncode(new TextEncoder().encode(JSON.stringify(data)));
}
/** A key pair standing in for the authenticator's credential. */
async function createCredential() {
const keyPair = await crypto.subtle.generateKey(
{ name: 'ECDSA', namedCurve: 'P-256' },
true,
['sign', 'verify'],
);
const spki = await crypto.subtle.exportKey('spki', keyPair.publicKey);
return {
privateKey: keyPair.privateKey,
publicKey: base64UrlEncode(new Uint8Array(spki)),
};
}
/**
* The 37-byte prefix a real authenticator emits: rpIdHash, flags, counter.
* @param {{rpId?: string, userPresent?: boolean, signCount?: number}} [options]
*/
async function makeAuthenticatorData({
rpId = RP_ID,
userPresent = true,
signCount = 0,
} = {}) {
const rpIdHash = new Uint8Array(
await crypto.subtle.digest('SHA-256', new TextEncoder().encode(rpId)),
);
const authData = new Uint8Array(37);
authData.set(rpIdHash, 0);
authData[32] = userPresent ? 0x01 : 0x00;
authData[33] = (signCount >>> 24) & 0xff;
authData[34] = (signCount >>> 16) & 0xff;
authData[35] = (signCount >>> 8) & 0xff;
authData[36] = signCount & 0xff;
return authData;
}
/**
* Sign an assertion the way an authenticator does: over authData followed by
* the hash of the client data.
*/
async function signAssertion(
/** @type {CryptoKey} */ privateKey,
/** @type {Uint8Array} */ authData,
/** @type {string} */ clientDataJSON,
) {
const clientDataHash = new Uint8Array(
await crypto.subtle.digest(
'SHA-256',
Uint8Array.from(
atob(clientDataJSON.replace(/-/g, '+').replace(/_/g, '/')),
(c) => c.charCodeAt(0),
),
),
);
const signed = new Uint8Array(authData.length + clientDataHash.length);
signed.set(authData, 0);
signed.set(clientDataHash, authData.length);
const raw = new Uint8Array(
await crypto.subtle.sign(
{ name: 'ECDSA', hash: 'SHA-256' },
privateKey,
signed,
),
);
return base64UrlEncode(rawToDerEcdsaSignature(raw));
}
/** WebCrypto signs raw r||s; a browser hands the server DER. */
function rawToDerEcdsaSignature(/** @type {Uint8Array} */ raw) {
const encodeInt = (/** @type {Uint8Array} */ bytes) => {
let start = 0;
while (start < bytes.length - 1 && bytes[start] === 0) start++;
let value = bytes.slice(start);
// DER integers are signed: a leading high bit needs a zero byte
if (value[0] & 0x80) value = Uint8Array.from([0, ...value]);
return Uint8Array.from([0x02, value.length, ...value]);
};
const r = encodeInt(raw.slice(0, 32));
const s = encodeInt(raw.slice(32));
return Uint8Array.from([0x30, r.length + s.length, ...r, ...s]);
}
describe('client data', () => {
test('accepts a matching ceremony', () => {
const clientDataJSON = encodeClientData({
type: 'webauthn.create',
challenge: CHALLENGE,
origin: ORIGIN,
});
expect(() =>
checkClientData(clientDataJSON, {
type: 'webauthn.create',
challenge: CHALLENGE,
origin: ORIGIN,
}),
).not.toThrow();
});
test('refuses a challenge from another request', () => {
const clientDataJSON = encodeClientData({
type: 'webauthn.get',
challenge: 'some-other-challenge',
origin: ORIGIN,
});
expect(() =>
checkClientData(clientDataJSON, {
type: 'webauthn.get',
challenge: CHALLENGE,
origin: ORIGIN,
}),
).toThrow(/Challenge mismatch/);
});
test('refuses a credential created for another origin', () => {
const clientDataJSON = encodeClientData({
type: 'webauthn.get',
challenge: CHALLENGE,
origin: 'https://evil.example',
});
expect(() =>
checkClientData(clientDataJSON, {
type: 'webauthn.get',
challenge: CHALLENGE,
origin: ORIGIN,
}),
).toThrow(/Origin mismatch/);
});
test('refuses a registration response to a sign-in request', () => {
const clientDataJSON = encodeClientData({
type: 'webauthn.create',
challenge: CHALLENGE,
origin: ORIGIN,
});
expect(() =>
checkClientData(clientDataJSON, {
type: 'webauthn.get',
challenge: CHALLENGE,
origin: ORIGIN,
}),
).toThrow(/webauthn.get/);
});
});
describe('registration', () => {
test('accepts a well-formed response', async () => {
const credential = await createCredential();
const result = await verifyRegistration({
credentialId: 'credential-one',
publicKey: credential.publicKey,
algorithm: COSE_ES256,
clientDataJSON: encodeClientData({
type: 'webauthn.create',
challenge: CHALLENGE,
origin: ORIGIN,
}),
expectedChallenge: CHALLENGE,
expectedOrigin: ORIGIN,
});
expect(result.credentialId).toBe('credential-one');
expect(result.algorithm).toBe(COSE_ES256);
});
test('refuses an algorithm this server cannot verify', async () => {
const credential = await createCredential();
await expect(
verifyRegistration({
credentialId: 'credential-one',
publicKey: credential.publicKey,
algorithm: -8, // EdDSA
clientDataJSON: encodeClientData({
type: 'webauthn.create',
challenge: CHALLENGE,
origin: ORIGIN,
}),
expectedChallenge: CHALLENGE,
expectedOrigin: ORIGIN,
}),
).rejects.toThrow(/Unsupported key algorithm/);
expect(isSupportedAlgorithm(-8)).toBe(false);
});
test('refuses a key that will not import', async () => {
await expect(
verifyRegistration({
credentialId: 'credential-one',
publicKey: base64UrlEncode(new Uint8Array([1, 2, 3])),
algorithm: COSE_ES256,
clientDataJSON: encodeClientData({
type: 'webauthn.create',
challenge: CHALLENGE,
origin: ORIGIN,
}),
expectedChallenge: CHALLENGE,
expectedOrigin: ORIGIN,
}),
).rejects.toThrow();
});
});
describe('assertion', () => {
/** Build a complete, valid assertion, then let a test spoil one part. */
async function makeAssertion(/** @type {any} */ overrides = {}) {
const credential = await createCredential();
const authData = await makeAuthenticatorData(overrides.authenticator);
const clientDataJSON = encodeClientData({
type: 'webauthn.get',
challenge: overrides.challenge ?? CHALLENGE,
origin: overrides.origin ?? ORIGIN,
...overrides.clientData,
});
const signature = await signAssertion(
overrides.signWith ?? credential.privateKey,
authData,
clientDataJSON,
);
return {
publicKey: credential.publicKey,
algorithm: COSE_ES256,
authenticatorData: base64UrlEncode(authData),
clientDataJSON,
signature,
expectedChallenge: CHALLENGE,
expectedOrigin: ORIGIN,
rpId: RP_ID,
};
}
test('accepts a signature from the registered key', async () => {
const result = await verifyAssertion(await makeAssertion());
expect(result.signCount).toBe(0);
});
test('reports the authenticator counter', async () => {
const assertion = await makeAssertion({
authenticator: { signCount: 41 },
});
expect((await verifyAssertion(assertion)).signCount).toBe(41);
});
test('refuses a signature from a different key', async () => {
const assertion = await makeAssertion();
const impostor = await createCredential();
await expect(
verifyAssertion({ ...assertion, publicKey: impostor.publicKey }),
).rejects.toThrow(/Signature is invalid/);
});
test('refuses authenticator data for another site', async () => {
const assertion = await makeAssertion({
authenticator: { rpId: 'evil.example' },
});
await expect(verifyAssertion(assertion)).rejects.toThrow(/different site/);
});
test('refuses an assertion with no user present', async () => {
const assertion = await makeAssertion({
authenticator: { userPresent: false },
});
await expect(verifyAssertion(assertion)).rejects.toThrow(/User presence/);
});
test('refuses a replayed challenge', async () => {
const assertion = await makeAssertion({ challenge: 'an-older-challenge' });
await expect(verifyAssertion(assertion)).rejects.toThrow(
/Challenge mismatch/,
);
});
test('refuses a tampered signature', async () => {
const assertion = await makeAssertion();
const bytes = Uint8Array.from(
atob(assertion.signature.replace(/-/g, '+').replace(/_/g, '/')),
(c) => c.charCodeAt(0),
);
bytes[bytes.length - 1] ^= 0xff;
await expect(
verifyAssertion({ ...assertion, signature: base64UrlEncode(bytes) }),
).rejects.toThrow();
});
});
describe('signature conversion', () => {
test('unpacks DER into fixed-width r and s', () => {
// r has a high bit set, so DER carries the extra sign byte
const der = Uint8Array.from([
0x30, 0x08, 0x02, 0x02, 0x00, 0x81, 0x02, 0x02, 0x00, 0x7f,
]);
const raw = derToRawEcdsaSignature(der);
expect(raw.length).toBe(64);
expect(raw[31]).toBe(0x81);
expect(raw[63]).toBe(0x7f);
expect(raw.slice(0, 31).every((b) => b === 0)).toBe(true);
});
test('rejects a non-sequence', () => {
expect(() =>
derToRawEcdsaSignature(Uint8Array.from([0x02, 0x01, 0x00])),
).toThrow(/Malformed/);
});
});
describe('sign counters', () => {
test('reads a big-endian counter', async () => {
expect(
readSignCount(await makeAuthenticatorData({ signCount: 70000 })),
).toBe(70000);
});
test('returns zero for truncated data', () => {
expect(readSignCount(new Uint8Array(10))).toBe(0);
});
});