SPB Git

Repository Public

soft-authenticator.ts 4.2 KB 85 lines typescript

1/**2 * Minimal software WebAuthn authenticator for tests: ES256 (P-256) credentials, `none`3 * attestation, real ECDSA signatures. Produces the JSON shapes a browser would send4 * (as @simplewebauthn/browser serialises them). Test-only: keys live in memory.5 */6import { createHash, createSign, generateKeyPairSync, randomBytes, type KeyObject } from 'node:crypto';78const b64u = (b: Buffer): string => b.toString('base64url');9const sha256 = (b: Buffer | string): Buffer => createHash('sha256').update(b).digest();1011// ---- just enough CBOR (RFC 8949) for an attestation object and a COSE key ----12function head(major: number, n: number): Buffer {13  if (n < 24) return Buffer.from([(major << 5) | n]);14  if (n < 256) return Buffer.from([(major << 5) | 24, n]);15  return Buffer.from([(major << 5) | 25, n >> 8, n & 0xff]);16}17type Cbor = number | string | Buffer | Map<number | string, Cbor>;18function cbor(v: Cbor): Buffer {19  if (typeof v === 'number') return v >= 0 ? head(0, v) : head(1, -1 - v);20  if (typeof v === 'string') return Buffer.concat([head(3, Buffer.byteLength(v)), Buffer.from(v)]);21  if (Buffer.isBuffer(v)) return Buffer.concat([head(2, v.length), v]);22  const parts: Buffer[] = [head(5, v.size)];23  for (const [k, val] of v) parts.push(cbor(k), cbor(val));24  return Buffer.concat(parts);25}2627export interface SoftOptions {28  origin: string;29  rpId: string;30  /** Set the UV flag (biometric / PIN verified). Default true. */31  userVerified?: boolean;32}3334export class SoftAuthenticator {35  readonly credentialId = randomBytes(32);36  private readonly privateKey: KeyObject;37  private readonly cose: Buffer;38  signCount = 0;39  userHandle: string | null = null;4041  constructor() {42    const { privateKey, publicKey } = generateKeyPairSync('ec', { namedCurve: 'P-256' });43    this.privateKey = privateKey;44    const jwk = publicKey.export({ format: 'jwk' }) as { x: string; y: string };45    this.cose = cbor(new Map<number, Cbor>([[1, 2], [3, -7], [-1, 1], [-2, Buffer.from(jwk.x, 'base64url')], [-3, Buffer.from(jwk.y, 'base64url')]]));46  }47  get id(): string {48    return b64u(this.credentialId);49  }5051  private authData(o: SoftOptions, attested: boolean): Buffer {52    const flags = 0x01 | (o.userVerified === false ? 0 : 0x04) | (attested ? 0x40 : 0);53    const count = Buffer.alloc(4);54    count.writeUInt32BE(this.signCount);55    const base = Buffer.concat([sha256(o.rpId), Buffer.from([flags]), count]);56    if (!attested) return base;57    const len = Buffer.alloc(2);58    len.writeUInt16BE(this.credentialId.length);59    return Buffer.concat([base, Buffer.alloc(16), len, this.credentialId, this.cose]);60  }6162  /** Response to `navigator.credentials.create()`. */63  register(options: { challenge: string; user: { id: string } }, o: SoftOptions): Record<string, unknown> {64    this.userHandle = options.user.id;65    const clientDataJSON = Buffer.from(JSON.stringify({ type: 'webauthn.create', challenge: options.challenge, origin: o.origin, crossOrigin: false }));66    const attestationObject = cbor(new Map<string, Cbor>([['fmt', 'none'], ['attStmt', new Map()], ['authData', this.authData(o, true)]]));67    return {68      id: this.id, rawId: this.id, type: 'public-key', authenticatorAttachment: 'platform', clientExtensionResults: {},69      response: { clientDataJSON: b64u(clientDataJSON), attestationObject: b64u(attestationObject), transports: ['internal'] },70    };71  }7273  /** Response to `navigator.credentials.get()`. Increments the signature counter unless told not to. */74  authenticate(options: { challenge: string }, o: SoftOptions & { keepCounter?: boolean }): Record<string, unknown> {75    if (!o.keepCounter) this.signCount++;76    const clientDataJSON = Buffer.from(JSON.stringify({ type: 'webauthn.get', challenge: options.challenge, origin: o.origin, crossOrigin: false }));77    const authenticatorData = this.authData(o, false);78    const signature = createSign('SHA256').update(Buffer.concat([authenticatorData, sha256(clientDataJSON)])).sign(this.privateKey);79    return {80      id: this.id, rawId: this.id, type: 'public-key', authenticatorAttachment: 'platform', clientExtensionResults: {},81      response: { clientDataJSON: b64u(clientDataJSON), authenticatorData: b64u(authenticatorData), signature: b64u(signature), userHandle: this.userHandle },82    };83  }84}85
Files main
Menu

Theme