chadtmiller.com/pds.js
An atproto PDS in JavaScript, for Cloudflare Workers and Node
0
0
// @pdsjs/core/webauthn - Passkey registration and assertion checks
//
// Only the two ceremonies a PDS needs, and no CBOR: the browser hands over the
// credential's public key already in SPKI form (`getPublicKey()`), so nothing
// here has to walk an attestation object or decode a COSE key.
//
// Attestation is not examined. It answers "what kind of authenticator is
// this", which matters to deployments enforcing an authenticator policy and
// not to a personal server: the registering user is already signed in with
// the account password, so they are entitled to add whatever key they hold.
// What must hold is that later assertions are signed by the key registered
// here, and that is what verifyAssertion checks.
import { base64UrlDecode, base64UrlEncode } from './crypto.js';
/**
* Schema for passkey storage. Defined once and imported by every adapter, so
* the table cannot drift between SQLite and Durable Objects.
*/
export const PASSKEY_SCHEMA_SQL = `
CREATE TABLE IF NOT EXISTS passkeys (
credential_id TEXT PRIMARY KEY,
did TEXT NOT NULL,
name TEXT NOT NULL,
public_key TEXT NOT NULL,
algorithm INTEGER NOT NULL,
sign_count INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL,
last_used_at TEXT
);
CREATE INDEX IF NOT EXISTS passkeys_did_idx ON passkeys(did);
`;
/** COSE algorithm identifiers this server accepts. */
export const COSE_ES256 = -7;
export const COSE_RS256 = -257;
/**
* Map a COSE algorithm to its WebCrypto import and verify parameters.
* @param {number} algorithm - COSE identifier from getPublicKeyAlgorithm()
* @returns {{importParams: EcKeyImportParams|RsaHashedImportParams, verifyParams: EcdsaParams|AlgorithmIdentifier}|null}
*/
function algorithmParams(algorithm) {
if (algorithm === COSE_ES256) {
return {
importParams: { name: 'ECDSA', namedCurve: 'P-256' },
verifyParams: { name: 'ECDSA', hash: 'SHA-256' },
};
}
if (algorithm === COSE_RS256) {
return {
importParams: { name: 'RSASSA-PKCS1-v1_5', hash: 'SHA-256' },
verifyParams: { name: 'RSASSA-PKCS1-v1_5' },
};
}
return null;
}
/**
* True for an algorithm this server can verify.
* @param {number} algorithm
* @returns {boolean}
*/
export function isSupportedAlgorithm(algorithm) {
return algorithmParams(algorithm) !== null;
}
/**
* Parse and check the clientDataJSON both ceremonies carry.
* @param {string} clientDataJSON - base64url of the raw bytes
* @param {{type: string, challenge: string, origin: string}} expected
* @throws {Error} If the ceremony, challenge or origin does not match
* @returns {Object} The parsed client data
*/
export function checkClientData(clientDataJSON, expected) {
let clientData;
try {
clientData = JSON.parse(
new TextDecoder().decode(base64UrlDecode(clientDataJSON)),
);
} catch {
throw new Error('Client data is not valid JSON');
}
if (clientData.type !== expected.type) {
throw new Error(`Expected a ${expected.type} ceremony`);
}
// The challenge proves this response answers the request this server just
// made, rather than one replayed from an earlier ceremony.
if (clientData.challenge !== expected.challenge) {
throw new Error('Challenge mismatch');
}
// The origin is what binds a passkey to this site: without it, a credential
// created on another origin could be presented here.
if (clientData.origin !== expected.origin) {
throw new Error('Origin mismatch');
}
return clientData;
}
/**
* SHA-256, as bytes.
* @param {Uint8Array} data
* @returns {Promise<Uint8Array>}
*/
async function sha256(data) {
return new Uint8Array(
await crypto.subtle.digest('SHA-256', /** @type {BufferSource} */ (data)),
);
}
/**
* Check the authenticator data's relying-party hash and flags.
* @param {Uint8Array} authData
* @param {string} rpId - The relying party id (this server's hostname)
* @throws {Error} If the data is for another site, or nobody was present
*/
async function checkAuthenticatorData(authData, rpId) {
if (authData.length < 37) throw new Error('Authenticator data is too short');
const expectedRpIdHash = await sha256(new TextEncoder().encode(rpId));
for (let i = 0; i < 32; i++) {
if (authData[i] !== expectedRpIdHash[i]) {
throw new Error('Authenticator data is for a different site');
}
}
// Bit 0 is "user present" — the authenticator saw a gesture, not just a
// process asking it to sign.
if ((authData[32] & 0x01) !== 0x01) {
throw new Error('User presence was not confirmed');
}
}
/**
* The signature counter an authenticator reports, or 0 when it keeps none.
* @param {Uint8Array} authData
* @returns {number}
*/
export function readSignCount(authData) {
if (authData.length < 37) return 0;
return (
authData[33] * 0x1000000 +
authData[34] * 0x10000 +
authData[35] * 0x100 +
authData[36]
);
}
/**
* Check a registration response and return what should be stored.
* @param {Object} params
* @param {string} params.credentialId - base64url of the credential's raw id
* @param {string} params.publicKey - base64url SPKI from getPublicKey()
* @param {number} params.algorithm - COSE id from getPublicKeyAlgorithm()
* @param {string} params.clientDataJSON - base64url of the raw bytes
* @param {string} params.expectedChallenge
* @param {string} params.expectedOrigin
* @returns {Promise<{credentialId: string, publicKey: string, algorithm: number}>}
* @throws {Error} If the response does not answer this server's request
*/
export async function verifyRegistration({
credentialId,
publicKey,
algorithm,
clientDataJSON,
expectedChallenge,
expectedOrigin,
}) {
if (!credentialId) throw new Error('Missing credential id');
if (!publicKey) throw new Error('Missing public key');
if (!isSupportedAlgorithm(algorithm)) {
throw new Error('Unsupported key algorithm');
}
checkClientData(clientDataJSON, {
type: 'webauthn.create',
challenge: expectedChallenge,
origin: expectedOrigin,
});
// Import once here so a key this server could never verify with is refused
// at registration rather than at the first sign-in attempt.
await importCredentialKey(publicKey, algorithm);
return { credentialId, publicKey, algorithm };
}
/**
* Import a stored SPKI public key for verification.
* @param {string} publicKey - base64url SPKI
* @param {number} algorithm - COSE identifier
* @returns {Promise<CryptoKey>}
*/
async function importCredentialKey(publicKey, algorithm) {
const params = algorithmParams(algorithm);
if (!params) throw new Error('Unsupported key algorithm');
return crypto.subtle.importKey(
'spki',
/** @type {BufferSource} */ (base64UrlDecode(publicKey)),
params.importParams,
false,
['verify'],
);
}
/**
* Check an authentication response against a stored credential.
* @param {Object} params
* @param {string} params.publicKey - base64url SPKI of the stored credential
* @param {number} params.algorithm - COSE identifier of the stored credential
* @param {string} params.authenticatorData - base64url of the raw bytes
* @param {string} params.clientDataJSON - base64url of the raw bytes
* @param {string} params.signature - base64url of the signature
* @param {string} params.expectedChallenge
* @param {string} params.expectedOrigin
* @param {string} params.rpId
* @returns {Promise<{signCount: number}>}
* @throws {Error} If anything about the assertion fails to check out
*/
export async function verifyAssertion({
publicKey,
algorithm,
authenticatorData,
clientDataJSON,
signature,
expectedChallenge,
expectedOrigin,
rpId,
}) {
checkClientData(clientDataJSON, {
type: 'webauthn.get',
challenge: expectedChallenge,
origin: expectedOrigin,
});
const authData = base64UrlDecode(authenticatorData);
await checkAuthenticatorData(authData, rpId);
// The authenticator signs its own data concatenated with a hash of the
// client data, which is what ties the signature to this challenge.
const clientDataHash = await sha256(base64UrlDecode(clientDataJSON));
const signedData = new Uint8Array(authData.length + clientDataHash.length);
signedData.set(authData, 0);
signedData.set(clientDataHash, authData.length);
const key = await importCredentialKey(publicKey, algorithm);
const params =
/** @type {NonNullable<ReturnType<typeof algorithmParams>>} */ (
algorithmParams(algorithm)
);
let sig = base64UrlDecode(signature);
if (algorithm === COSE_ES256) {
// WebAuthn signs ECDSA in DER; WebCrypto verifies raw r||s.
sig = derToRawEcdsaSignature(sig);
}
const valid = await crypto.subtle.verify(
params.verifyParams,
key,
/** @type {BufferSource} */ (sig),
/** @type {BufferSource} */ (signedData),
);
if (!valid) throw new Error('Signature is invalid');
return { signCount: readSignCount(authData) };
}
/**
* Convert a DER-encoded ECDSA signature to the raw r||s WebCrypto expects.
* @param {Uint8Array} der
* @returns {Uint8Array} 64 bytes
* @throws {Error} If the encoding is not a two-integer SEQUENCE
*/
export function derToRawEcdsaSignature(der) {
if (der[0] !== 0x30) throw new Error('Malformed ECDSA signature');
// SEQUENCE header, then INTEGER r, then INTEGER s. Lengths here are always
// short-form: r and s are at most 33 bytes for P-256.
let offset = 2;
const readInt = () => {
if (der[offset++] !== 0x02) throw new Error('Malformed ECDSA signature');
const length = der[offset++];
let value = der.slice(offset, offset + length);
offset += length;
// Strip the sign byte DER adds to a value whose high bit is set, then pad
// back to the fixed 32 the raw form wants.
while (value.length > 32 && value[0] === 0x00) value = value.slice(1);
if (value.length > 32) throw new Error('Malformed ECDSA signature');
const padded = new Uint8Array(32);
padded.set(value, 32 - value.length);
return padded;
};
const r = readInt();
const s = readInt();
const raw = new Uint8Array(64);
raw.set(r, 0);
raw.set(s, 32);
return raw;
}
/**
* A fresh challenge for either ceremony.
* @returns {string} base64url
*/
export function generateChallenge() {
return base64UrlEncode(crypto.getRandomValues(new Uint8Array(32)));
}