SPB Git

Repository Public

authenticator.ts 3.6 KB 46 lines typescript

1// A software WebAuthn authenticator for tests: real ES256 keys, real signatures, so the server-side verification2// under test is the production code path (attestation "none", user verified).3import { createHash, createSign, generateKeyPairSync, type KeyObject, randomBytes } from 'node:crypto';45const b64u = (b: Buffer | Uint8Array) => Buffer.from(b).toString('base64url');6// minimal CBOR encoder: unsigned/negative ints, byte strings, text strings, maps7function cbor(v: unknown): Buffer {8  const head = (major: number, n: number) => n < 24 ? Buffer.from([(major << 5) | n]) : n < 256 ? Buffer.from([(major << 5) | 24, n]) : Buffer.from([(major << 5) | 25, n >> 8, n & 255]);9  if (typeof v === 'number') return v >= 0 ? head(0, v) : head(1, -1 - v);10  if (Buffer.isBuffer(v)) return Buffer.concat([head(2, v.length), v]);11  if (typeof v === 'string') { const b = Buffer.from(v); return Buffer.concat([head(3, b.length), b]); }12  if (v instanceof Map) return Buffer.concat([head(5, v.size), ...[...v].flatMap(([k, x]) => [cbor(k), cbor(x)])]);13  throw new Error('cbor: unsupported');14}15export class SoftAuthenticator {16  private creds = new Map<string, { key: KeyObject; userHandle: string; counter: number }>();17  constructor(private origin: string, private rpId: string) {}18  private authData(flags: number, counter: number, attested?: Buffer): Buffer {19    const c = Buffer.alloc(4); c.writeUInt32BE(counter);20    return Buffer.concat([createHash('sha256').update(this.rpId).digest(), Buffer.from([flags]), c, ...(attested ? [attested] : [])]);21  }22  /** Answer navigator.credentials.create(). */23  create(options: { challenge: string; user: { id: string } }) {24    const { privateKey, publicKey } = generateKeyPairSync('ec', { namedCurve: 'P-256' });25    const jwk = publicKey.export({ format: 'jwk' });26    const id = randomBytes(32);27    const cose = cbor(new Map<number, unknown>([[1, 2], [3, -7], [-1, 1], [-2, Buffer.from(jwk.x!, 'base64url')], [-3, Buffer.from(jwk.y!, 'base64url')]]));28    const len = Buffer.alloc(2); len.writeUInt16BE(id.length);29    const authData = this.authData(0x45, 0, Buffer.concat([Buffer.alloc(16), len, id, cose])); // UP | UV | AT30    this.creds.set(b64u(id), { key: privateKey, userHandle: options.user.id, counter: 0 });31    const clientDataJSON = Buffer.from(JSON.stringify({ type: 'webauthn.create', challenge: options.challenge, origin: this.origin, crossOrigin: false }));32    return { id: b64u(id), rawId: b64u(id), type: 'public-key', clientExtensionResults: {}, authenticatorAttachment: 'platform',33      response: { clientDataJSON: b64u(clientDataJSON), attestationObject: b64u(cbor(new Map<string, unknown>([['fmt', 'none'], ['attStmt', new Map()], ['authData', authData]]))), transports: ['internal'] } };34  }35  /** Answer navigator.credentials.get() with a discoverable credential (or the given one). */36  get(options: { challenge: string }, credentialId?: string, o: { origin?: string } = {}) {37    const id = credentialId ?? [...this.creds.keys()].pop()!;38    const c = this.creds.get(id)!;39    const authData = this.authData(0x05, ++c.counter);40    const clientDataJSON = Buffer.from(JSON.stringify({ type: 'webauthn.get', challenge: options.challenge, origin: o.origin ?? this.origin, crossOrigin: false }));41    const signature = createSign('sha256').update(Buffer.concat([authData, createHash('sha256').update(clientDataJSON).digest()])).sign(c.key);42    return { id, rawId: id, type: 'public-key', clientExtensionResults: {}, authenticatorAttachment: 'platform',43      response: { clientDataJSON: b64u(clientDataJSON), authenticatorData: b64u(authData), signature: b64u(signature), userHandle: c.userHandle } };44  }45}46
Files main
Menu

Theme