UNPKG

@didtools/key-webauthn

Version:

Implements support to authenticate, authorize and verify blocks produced by webauthn/passkey compatible hardware authenticators and OS/software implementations.

290 lines (289 loc) 11.1 kB
/** * ## Webauthn AuthMethod and Verifier * * Implements support to authenticate, authorize and verify blocks produced * by webauthn/passkey compatible hardware authenticators and OS/software implementations. * * ## Installation * * ``` * npm install --save @didtools/key-webauthn * ``` * * ## Auth Usage * * This module is designed to run in browser environments. * * Create a Credential for first time use: * ```js * import { WebauthnAuth } from '@didtools/key-webauthn' * * const did = await WebauthnAuth.createDid('app-user') * * const authMethod = await WebauthnAuth.getAuthMethod({ did }) * const session = await DIDSession.authorize(authMethod, { resources: ['ceramic://nil'] }) * ``` * * ## Verifier Usage * * Verifiers are needed to verify different did:pkh signed payloads using CACAO. Libraries that need them will * consume a verifiers map allowing your to register the verifiers you want to support. * * ```js * import { Cacao } from '@didtools/cacao' * import { WebauthnAuth } from '@didtools/key-webauthn' * import { DID } from 'dids' * * const verifiers = { * ...WebauthnAuth.getVerifier() * } * * // Directly with cacao * Cacao.verify(cacao, { verifiers, ...opts}) * * // With DIDS, reference DIDS for more details * const dids = // configured dids instance * await dids.verifyJWS(jws, { capability, verifiers, ...opts}) * ``` * * ## Caveat: DID selection * * The webauthn+fido2 standard was originally developed for use with databases and at that time * a pesudo random `CredentialID` was preferred over the use of public keys. * * The public key is exported only **once** when the credential is created - spec limitation. * There are 3 options for `getAuthMethod()` * * #### Option 1. Known DID * * ```js * import { WebauthnAuth } from '@didtools/key-webauthn' * * const authMethod = WebauthnAuth.getAuthMethod({ did: 'did:key:zDn...' }) * ``` * #### Option 2. Probe * * Probe the authenticator for public keys by asking user to sign a nonce: * * ```js * import { WebauthnAuth } from '@didtools/key-webauthn' * * const dids = await WebauthnAuth.probeDIDs() * const authMethod = WebauthnAuth.getAuthMethod({ dids }) * ``` * * #### Option 3. Callback * * Use a callback with the following call signature: * * ```ts * (did1: string, did2: string) => Promise<string> * ``` * * Example that probes on-demand: * ```js * import { WebauthnAuth } from '@didtools/key-webauthn' * * const selectDIDs = async (did1, did2) { * const dids = await WebauthnAuth.probeDIDs() * if (dids.includes(did1)) return did1 * else return did2 * } * * const authMethod = WebauthnAuth.getAuthMethod({ selectDIDs }) * ``` * * @module @didtools/key-webauthn */ import * as dagCbor from '@ipld/dag-cbor'; import * as Block from 'multiformats/block'; // Monkeypatch workaround import { sha256 as hasher } from 'multiformats/hashes/sha2'; // Monkeypatch workaround import varint from 'varint'; import * as u8a from 'uint8arrays'; import { encode, decode } from 'cborg'; import { getAuthenticatorData, decodeAuthenticatorData, authenticatorSign, verify, assertU8, randomBytes, encodeDIDFromPub } from './utils.js'; // Workaround for CacaoBlock.fromCacao(): https://github.com/multiformats/js-multiformats/issues/259 const blockFromCacao = (cacao)=>{ return Block.encode({ value: cacao, codec: dagCbor, hasher: { ...hasher, // monkeypatch Buffer to Uint8Array conversion digest: (bytes)=>hasher.digest(assertU8(bytes)) } }); }; export var WebauthnAuth; (function(WebauthnAuth) { async function createDID(label) { const opts = typeof label === 'string' ? p256CredentialCreateOptions(label, label) : label; const credentials = globalThis.navigator.credentials; const credential = await credentials.create(opts); if (!credential) throw new Error('Empty Credential Response'); // @ts-ignore CredentialsContainer does contain response const { response } = credential; const authenticatorData = getAuthenticatorData(response); const { publicKey } = decodeAuthenticatorData(authenticatorData); return encodeDIDFromPub(publicKey); } WebauthnAuth.createDID = createDID; async function getAuthMethod(didOpts) { const { did, dids } = didOpts; let selectDID; if (didOpts.selectDID) selectDID = didOpts.selectDID // Use callback ; else if (did) selectDID = async ()=>did // Use known DID ; else if (dids) selectDID = async (a, b)=>dids.find((x)=>[ a, b ].includes(x)) // Use probe result ; else throw new Error('getAuthMethod({ did|dids|selectDID }) expects one resolution option'); return async (opts)=>{ return createCacao(opts, selectDID); }; } WebauthnAuth.getAuthMethod = getAuthMethod; async function createCacao(opts, selectDID) { const now = new Date(); // The public key is not known at pre-sign time. // so we sign a "challenge"-block without Issuer attribute const challenge = { h: { t: 'caip122' }, p: { domain: globalThis.location.hostname, aud: opts.uri || globalThis.location.toString(), version: '1', nonce: opts.nonce || u8a.toString(randomBytes(8), 'base64url'), resources: opts.resources, exp: opts.expirationTime || new Date(now.getTime() + 7 * 86400000).toISOString(), nbf: opts.notBefore || now.toISOString(), iat: opts.issuedAt || now.toISOString() } }; const block = await blockFromCacao(challenge) // await CacaoBlock.fromCacao(challenge) when issue resolved ; // perform sign const { credential, recovered } = await authenticatorSign(block.cid.bytes); // @ts-ignore - credential.response does exist. const { response } = credential; const { clientDataJSON, signature } = response; const authData = getAuthenticatorData(response); const aad = assertU8(encode({ authData, clientDataJSON })); const recoveredDIDs = recovered.map(encodeDIDFromPub); const iss = await selectDID(recoveredDIDs[0], recoveredDIDs[1]); if (!iss) throw new Error('PublicKeySelectionFailed'); // Assert that the resolved key belongs to the signature if (!recoveredDIDs.includes(iss)) throw new Error('UnrelatedPublickey'); // Insert iss + aad after signature return { h: challenge.h, p: { ...challenge.p, iss }, s: { t: 'webauthn:p256', s: u8a.toString(assertU8(signature), 'base64url'), m: { aad } } }; } async function probeDIDs() { const res = await authenticatorSign(randomBytes(32)); return res.recovered.map(encodeDIDFromPub); } /** * Ask user to sign a random challenge * @returns {Promise<Array<string>>} Two potential DIDs */ WebauthnAuth.probeDIDs = probeDIDs; function getVerifier() { return { 'webauthn:p256': verifyCacao }; } WebauthnAuth.getVerifier = getVerifier; /** * 1. Recreates cacao-challenge and message hash * 2. Verifies Signature of clientDataJSON * 3. Unpacks clientDataJSON and assert embedded hash against message hash */ async function verifyCacao(cacao, _) { if (!cacao.s) throw new Error('Cacao Signature Construct missing'); // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access const aad = cacao.s.m?.aad; if (!aad) throw new Error('AdditionalAuthenticatorData missing'); const { authData, clientDataJSON } = decode(assertU8(aad)); if (!cacao.s.s && typeof cacao.s.s !== 'string') throw new Error('Signature missing'); const signature = u8a.fromString(cacao.s.s, 'base64url'); if (!cacao.p?.iss) throw new Error('Issuer missing'); const bIss = u8a.fromString(cacao.p.iss.slice('did:key:z'.length), 'base58btc'); if (varint.decode(bIss) !== 0x1200) throw new Error('expected PublicKey to belong to curve p256'); const publicKey = bIss.slice(varint.decode.bytes); // Verify clientData authencity const valid = verify(signature, publicKey, authData, clientDataJSON); if (!valid) throw new Error('InvalidMessage'); // Verify clientDataJSON.challenge equals message hash const clientData = JSON.parse(u8a.toString(clientDataJSON, 'utf8')); if (clientData.type !== 'webauthn.get') throw new Error('Invalid clientDataJSON.type'); const expectedHash = u8a.fromString(clientData.challenge, 'base64url'); const challenge = { ...cacao, p: { ...cacao.p } } // deep-clone ; delete challenge.s // remove signature ; // @ts-ignore delete challenge.p.iss // remove issuer ; const block = await blockFromCacao(challenge) // await CacaoBlock.fromCacao(challenge) ; // Compare reproduced challenge-hash to signed hash if (u8a.compare(expectedHash, block.cid.bytes) !== 0) throw new Error('MessageMismatch'); } function p256CredentialCreateOptions(name = 'key-webauthn', displayName = 'Ceramic Auth Provider', rpname = globalThis.location.hostname) { return { publicKey: { challenge: randomBytes(32), rp: { id: globalThis.location.hostname, name: rpname // A known constant. }, user: { id: randomBytes(32), name, displayName }, pubKeyCredParams: [ { type: 'public-key', alg: -7 } ], authenticatorSelection: { requireResidentKey: true, residentKey: 'required', userVerification: 'required' } } }; } /** * A simple approach to create a discoverable * credential with sane defaults. * @param {string} name username|email|user-alias * @param {string} displayName Human friendly identifier of credential, shown in OS-popups. * @param {string} rpname (RelayingPartyName) name of the app. * @returns {CredentialCreationOptions} An options object that can be passed to credentials.create(opts) */ WebauthnAuth.p256CredentialCreateOptions = p256CredentialCreateOptions; })(WebauthnAuth || (WebauthnAuth = {}));