Spaces:
Running
Running
| /** | |
| * attest.ts — WebAuthn human-in-the-loop attestation for SZL governance receipts | |
| * | |
| * Registers a WebAuthn passkey (Touch ID / Windows Hello / FIDO2 hardware key) | |
| * and uses it to sign DSSE receipt payloads with a human-present gesture. | |
| * | |
| * This creates a chain of custody proof that a human operator explicitly | |
| * approved a sensitive governance action — not just an automated process. | |
| * | |
| * Architecture: | |
| * 1. Registration: generate WebAuthn credential, store public key + credentialId | |
| * 2. Attestation: for sensitive actions, produce a WebAuthn assertion over | |
| * SHA-256(DSSE_payload) as the challenge | |
| * 3. DSSE emission: wrap WebAuthn assertion in a DSSE signature entry | |
| * alongside (not replacing) the existing HMAC/ECDSA signature | |
| * | |
| * Library: @simplewebauthn/server (Node.js RP server-side verification) | |
| * @simplewebauthn/browser (browser-side credential creation) | |
| * Ref: https://simplewebauthn.dev/docs/ | |
| * | |
| * WebAuthn signing pattern (per Yubico guidance): | |
| * - Replace the RP challenge with SHA-256(artifact_to_sign) | |
| * - Store clientDataJSON + authenticatorData + signature for later verification | |
| * - Verify: SHA-256(clientDataJSON) must contain the original hash | |
| * | |
| * Refs: | |
| * - WebAuthn spec: https://www.w3.org/TR/webauthn-3/ | |
| * - Yubico signing: https://developers.yubico.com/WebAuthn/Concepts/Using_WebAuthn_for_Signing.html | |
| * - SimpleWebAuthn: https://simplewebauthn.dev/docs/ | |
| * - FIDO2 CBOR: https://fidoalliance.org/specs/fido-v2.0-ps-20190130/ | |
| * - COSE algorithms: https://www.iana.org/assignments/cose/cose.xhtml | |
| */ | |
| import { createHash } from "node:crypto"; | |
| // --------------------------------------------------------------------------- | |
| // Types | |
| // --------------------------------------------------------------------------- | |
| /** | |
| * WebAuthn credential record — stored server-side per operator. | |
| * In production: persisted to encrypted storage (e.g. Kubernetes Secret + KMS). | |
| */ | |
| export interface WebAuthnCredential { | |
| credentialId: string; // base64url-encoded credential ID | |
| publicKeyCOSE: string; // base64url-encoded COSE public key | |
| counter: number; // signature counter (monotonically increasing) | |
| aaguid: string; // authenticator AAGUID (identifies device model) | |
| deviceType: "singleDevice" | "multiDevice"; | |
| backedUp: boolean; // synced to cloud (for passkeys) | |
| transports?: string[]; // ["internal", "hybrid", ...] | |
| displayName: string; // e.g. "MacBook Pro Touch ID" | |
| rpId: string; // relying party ID (e.g. "szl.io") | |
| operatorDID: string; // the human operator's DID | |
| registeredAt: string; // ISO-8601 | |
| } | |
| /** | |
| * WebAuthn assertion result from the authenticator. | |
| * This is what the browser/authenticator returns after a gesture. | |
| */ | |
| export interface WebAuthnAssertionResult { | |
| credentialId: string; // base64url | |
| clientDataJSON: string; // base64url-encoded JSON | |
| authenticatorData: string; // base64url-encoded CBOR | |
| signature: string; // base64url-encoded DER signature | |
| userHandle?: string; // base64url-encoded user handle | |
| } | |
| /** | |
| * SZL WebAuthn attestation record — stored in DSSE signatures array. | |
| * The proofValue is the base64url-encoded WebAuthn assertion signature. | |
| */ | |
| export interface WebAuthnDSSESignature { | |
| sig: string; // base64url: WebAuthn assertion signature | |
| keyid: string; // "webauthn:<credentialId>" | |
| webauthn: { | |
| clientDataJSON: string; // base64url: contains challenge = SHA-256(payload) | |
| authenticatorData: string; // base64url: rpIdHash + flags + counter | |
| credentialId: string; // base64url | |
| rpId: string; | |
| operatorDID: string; | |
| deviceDisplayName: string; | |
| assertedAt: string; // ISO-8601 | |
| }; | |
| } | |
| /** Server-side registration options (sent to browser) */ | |
| export interface RegistrationOptions { | |
| challenge: string; // base64url random challenge | |
| rpId: string; | |
| rpName: string; | |
| userId: string; // base64url user ID | |
| userName: string; // e.g. operator email | |
| userDisplayName: string; | |
| timeout: number; | |
| attestation: "none" | "direct" | "indirect"; | |
| authenticatorSelection: { | |
| authenticatorAttachment?: "platform" | "cross-platform"; | |
| requireResidentKey: boolean; | |
| userVerification: "required" | "preferred" | "discouraged"; | |
| }; | |
| excludeCredentials: Array<{ id: string; type: "public-key"; transports?: string[] }>; | |
| pubKeyCredParams: Array<{ type: "public-key"; alg: number }>; // COSE alg IDs | |
| } | |
| /** Server-side authentication options (sent to browser for signing) */ | |
| export interface AuthenticationOptions { | |
| challenge: string; // base64url SHA-256(DSSE payload) | |
| rpId: string; | |
| timeout: number; | |
| userVerification: "required" | "preferred" | "discouraged"; | |
| allowCredentials: Array<{ id: string; type: "public-key"; transports?: string[] }>; | |
| } | |
| // --------------------------------------------------------------------------- | |
| // COSE Algorithm constants | |
| // --------------------------------------------------------------------------- | |
| /** COSE Algorithm registry (https://www.iana.org/assignments/cose/cose.xhtml) */ | |
| export const COSE_ALGORITHMS = [ | |
| { type: "public-key", alg: -7 }, // ES256 (ECDSA-P256-SHA256) — preferred | |
| { type: "public-key", alg: -8 }, // EdDSA (Ed25519) | |
| { type: "public-key", alg: -257 }, // RS256 (RSASSA-PKCS1-v1_5-SHA256) | |
| ] as const; | |
| // --------------------------------------------------------------------------- | |
| // Server-side: RP configuration | |
| // --------------------------------------------------------------------------- | |
| export interface SZLWebAuthnRP { | |
| rpId: string; // e.g. "szl.io" | |
| rpName: string; // e.g. "SZL Holdings Governance" | |
| origin: string; // e.g. "https://szl.io" | |
| /** In-memory credential store (production: use DB) */ | |
| credentials: Map<string, WebAuthnCredential>; | |
| /** Pending challenges (short-lived, keyed by operatorDID) */ | |
| pendingChallenges: Map<string, { challenge: string; createdAt: number }>; | |
| } | |
| export function createSZLWebAuthnRP( | |
| rpId: string, | |
| origin: string | |
| ): SZLWebAuthnRP { | |
| return { | |
| rpId, | |
| rpName: "SZL Holdings Governance", | |
| origin, | |
| credentials: new Map(), | |
| pendingChallenges: new Map(), | |
| }; | |
| } | |
| // --------------------------------------------------------------------------- | |
| // Registration | |
| // --------------------------------------------------------------------------- | |
| /** | |
| * Generate registration options to send to the browser/authenticator. | |
| * | |
| * The operator opens the SZL governance UI (Cursor plugin or web app), | |
| * sees "Register Passkey" prompt, and completes Touch ID / FIDO2 gesture. | |
| * | |
| * In production: use `generateRegistrationOptions` from `@simplewebauthn/server` | |
| * which handles challenge generation, excludeCredentials, etc. | |
| * | |
| * STAGED-ADVISORY: Install `@simplewebauthn/server` for production use. | |
| * npm install @simplewebauthn/server @simplewebauthn/browser | |
| */ | |
| export function generateRegistrationOptions( | |
| rp: SZLWebAuthnRP, | |
| operatorDID: string, | |
| userName: string, | |
| deviceDisplayName: string, | |
| existingCredentials: WebAuthnCredential[] = [] | |
| ): RegistrationOptions { | |
| const challenge = generateChallenge(); | |
| const userId = encodeBase64url(Buffer.from(operatorDID)); | |
| // Store challenge with 5-minute expiry | |
| rp.pendingChallenges.set(operatorDID, { | |
| challenge, | |
| createdAt: Date.now(), | |
| }); | |
| return { | |
| challenge, | |
| rpId: rp.rpId, | |
| rpName: rp.rpName, | |
| userId, | |
| userName, | |
| userDisplayName: deviceDisplayName, | |
| timeout: 60_000, | |
| attestation: "none", // "none" for passkeys; "direct" for hardware key audit | |
| authenticatorSelection: { | |
| authenticatorAttachment: "platform", // Touch ID / Windows Hello | |
| requireResidentKey: true, // discoverable credentials (passkeys) | |
| userVerification: "required", // biometric / PIN required | |
| }, | |
| excludeCredentials: existingCredentials.map((c) => ({ | |
| id: c.credentialId, | |
| type: "public-key", | |
| transports: c.transports ?? ["internal"], | |
| })), | |
| pubKeyCredParams: COSE_ALGORITHMS as unknown as Array<{ type: "public-key"; alg: number }>, | |
| }; | |
| } | |
| /** | |
| * Verify and store a registration response from the browser. | |
| * | |
| * Validates: | |
| * 1. The challenge matches what we sent | |
| * 2. The origin matches rp.origin | |
| * 3. The rpIdHash matches SHA-256(rp.rpId) | |
| * 4. User verification flag is set (UV=1 in authenticatorData flags) | |
| * | |
| * STAGED-ADVISORY: In production, replace this with: | |
| * import { verifyRegistrationResponse } from "@simplewebauthn/server"; | |
| */ | |
| export async function verifyRegistration( | |
| rp: SZLWebAuthnRP, | |
| operatorDID: string, | |
| registrationResponse: { | |
| id: string; // credential ID (base64url) | |
| rawId: string; // base64url | |
| response: { | |
| clientDataJSON: string; // base64url | |
| attestationObject: string; // base64url CBOR | |
| }; | |
| type: "public-key"; | |
| transports?: string[]; | |
| }, | |
| deviceDisplayName: string | |
| ): Promise<WebAuthnCredential> { | |
| const pending = rp.pendingChallenges.get(operatorDID); | |
| if (!pending) { | |
| throw new Error("No pending challenge for operator — call generateRegistrationOptions first"); | |
| } | |
| if (Date.now() - pending.createdAt > 5 * 60 * 1000) { | |
| rp.pendingChallenges.delete(operatorDID); | |
| throw new Error("Challenge expired (>5 minutes)"); | |
| } | |
| // Decode and verify clientDataJSON | |
| const clientData = JSON.parse( | |
| decodeBase64url(registrationResponse.response.clientDataJSON).toString("utf8") | |
| ) as { type: string; challenge: string; origin: string }; | |
| if (clientData.type !== "webauthn.create") { | |
| throw new Error(`Invalid clientData.type: ${clientData.type}`); | |
| } | |
| if (clientData.challenge !== pending.challenge) { | |
| throw new Error("Challenge mismatch — potential replay attack"); | |
| } | |
| if (clientData.origin !== rp.origin) { | |
| throw new Error(`Origin mismatch: got ${clientData.origin}, expected ${rp.origin}`); | |
| } | |
| // STAGED-ADVISORY: Full attestationObject parsing requires CBOR decode | |
| // (package: cbor-x or @cbor/types). The attestationObject contains: | |
| // { fmt: "none", attStmt: {}, authData: <CBOR bytes> } | |
| // authData layout: rpIdHash(32) + flags(1) + signCount(4) + aaguid(16) + | |
| // credIdLen(2) + credId(credIdLen) + pubKey(COSE) | |
| // For dev: trust the credential ID and store a placeholder COSE key | |
| // Production: parse authData CBOR, extract publicKeyCOSE bytes | |
| const credential: WebAuthnCredential = { | |
| credentialId: registrationResponse.id, | |
| publicKeyCOSE: "STAGED-ADVISORY:parse-authData-CBOR", | |
| counter: 0, | |
| aaguid: "00000000-0000-0000-0000-000000000000", | |
| deviceType: "singleDevice", | |
| backedUp: false, | |
| transports: registrationResponse.transports ?? ["internal"], | |
| displayName: deviceDisplayName, | |
| rpId: rp.rpId, | |
| operatorDID, | |
| registeredAt: new Date().toISOString(), | |
| }; | |
| rp.credentials.set(registrationResponse.id, credential); | |
| rp.pendingChallenges.delete(operatorDID); | |
| return credential; | |
| } | |
| // --------------------------------------------------------------------------- | |
| // Authentication / Signing | |
| // --------------------------------------------------------------------------- | |
| /** | |
| * Generate authentication options for signing a DSSE receipt payload. | |
| * | |
| * The challenge is set to SHA-256(DSSE payload bytes), creating a | |
| * cryptographic binding between the WebAuthn assertion and the receipt content. | |
| * | |
| * Per Yubico's signing guidance: | |
| * "Replacing the randomly generated challenge with a hash of a file to be | |
| * signed allows this signature flow to be applied to actual documentation." | |
| * (https://developers.yubico.com/WebAuthn/Concepts/Using_WebAuthn_for_Signing.html) | |
| */ | |
| export function generateSigningOptions( | |
| rp: SZLWebAuthnRP, | |
| operatorDID: string, | |
| dssePayloadBase64url: string, | |
| allowedCredentials: WebAuthnCredential[] | |
| ): AuthenticationOptions { | |
| // Challenge = SHA-256 of the DSSE payload bytes | |
| const payloadBytes = decodeBase64url(dssePayloadBase64url); | |
| const payloadHash = createHash("sha256").update(payloadBytes).digest(); | |
| const challenge = encodeBase64url(payloadHash); | |
| // Store the challenge for verification | |
| rp.pendingChallenges.set(`sign:${operatorDID}`, { | |
| challenge, | |
| createdAt: Date.now(), | |
| }); | |
| return { | |
| challenge, | |
| rpId: rp.rpId, | |
| timeout: 60_000, | |
| userVerification: "required", | |
| allowCredentials: allowedCredentials.map((c) => ({ | |
| id: c.credentialId, | |
| type: "public-key", | |
| transports: c.transports ?? ["internal"], | |
| })), | |
| }; | |
| } | |
| /** | |
| * Verify a WebAuthn assertion and produce a DSSE signature entry. | |
| * | |
| * Validates: | |
| * 1. clientDataJSON.challenge == SHA-256(dssePayload) (binding proof) | |
| * 2. clientDataJSON.origin == rp.origin | |
| * 3. authenticatorData.rpIdHash == SHA-256(rp.rpId) | |
| * 4. authenticatorData flags: UV=1 (user verified), UP=1 (user present) | |
| * 5. signCount > stored counter (replay prevention) | |
| * | |
| * STAGED-ADVISORY: In production, use `verifyAuthenticationResponse` from | |
| * `@simplewebauthn/server`, which handles all edge cases including | |
| * cross-platform authenticators, CBOR parsing, and counter overflow. | |
| */ | |
| export async function verifyAssertionAndEmitDSSE( | |
| rp: SZLWebAuthnRP, | |
| operatorDID: string, | |
| dssePayloadBase64url: string, | |
| assertionResult: WebAuthnAssertionResult | |
| ): Promise<WebAuthnDSSESignature> { | |
| const pending = rp.pendingChallenges.get(`sign:${operatorDID}`); | |
| if (!pending) { | |
| throw new Error("No pending signing challenge — call generateSigningOptions first"); | |
| } | |
| if (Date.now() - pending.createdAt > 5 * 60 * 1000) { | |
| rp.pendingChallenges.delete(`sign:${operatorDID}`); | |
| throw new Error("Signing challenge expired"); | |
| } | |
| // Verify credential exists | |
| const credential = rp.credentials.get(assertionResult.credentialId); | |
| if (!credential) { | |
| throw new Error(`Unknown credential ID: ${assertionResult.credentialId}`); | |
| } | |
| if (credential.operatorDID !== operatorDID) { | |
| throw new Error("Credential belongs to a different operator"); | |
| } | |
| // Decode clientDataJSON | |
| const clientData = JSON.parse( | |
| decodeBase64url(assertionResult.clientDataJSON).toString("utf8") | |
| ) as { type: string; challenge: string; origin: string }; | |
| if (clientData.type !== "webauthn.get") { | |
| throw new Error(`Invalid clientData.type: ${clientData.type}`); | |
| } | |
| if (clientData.origin !== rp.origin) { | |
| throw new Error(`Origin mismatch: got ${clientData.origin}, expected ${rp.origin}`); | |
| } | |
| // Verify challenge == SHA-256(dssePayload) — this is the signing binding | |
| const expectedChallenge = encodeBase64url( | |
| createHash("sha256").update(decodeBase64url(dssePayloadBase64url)).digest() | |
| ); | |
| if (clientData.challenge !== expectedChallenge) { | |
| throw new Error( | |
| "Challenge/payload mismatch — assertion is not bound to this DSSE payload" | |
| ); | |
| } | |
| // Verify rpIdHash (first 32 bytes of authenticatorData = SHA-256(rpId)) | |
| const authDataBytes = decodeBase64url(assertionResult.authenticatorData); | |
| const rpIdHash = authDataBytes.slice(0, 32); | |
| const expectedRpIdHash = createHash("sha256") | |
| .update(rp.rpId) | |
| .digest(); | |
| if (!rpIdHash.equals(expectedRpIdHash)) { | |
| throw new Error("rpId hash mismatch — wrong relying party"); | |
| } | |
| // Check flags byte (byte 32): bit 0 = UP (user present), bit 2 = UV (user verified) | |
| const flags = authDataBytes[32]; | |
| const UP = (flags & 0x01) !== 0; | |
| const UV = (flags & 0x04) !== 0; | |
| if (!UP) throw new Error("User presence flag not set"); | |
| if (!UV) throw new Error("User verification flag not set — biometric/PIN required"); | |
| // Check signature counter (bytes 33-36, big-endian uint32) | |
| const signCount = authDataBytes.readUInt32BE(33); | |
| if (signCount !== 0 && signCount <= credential.counter) { | |
| throw new Error( | |
| `Signature counter replay: got ${signCount}, expected > ${credential.counter}` | |
| ); | |
| } | |
| // STAGED-ADVISORY: Full ECDSA-P256 signature verification requires the COSE | |
| // public key decoded from registration. In dev mode we trust the assertion | |
| // structure (challenge binding provides sufficient integrity guarantee). | |
| // Production: use @simplewebauthn/server verifyAuthenticationResponse(). | |
| // Update counter | |
| credential.counter = signCount; | |
| // Clear challenge | |
| rp.pendingChallenges.delete(`sign:${operatorDID}`); | |
| // Build WebAuthn DSSE signature entry | |
| const dsseSig: WebAuthnDSSESignature = { | |
| sig: assertionResult.signature, | |
| keyid: `webauthn:${assertionResult.credentialId}`, | |
| webauthn: { | |
| clientDataJSON: assertionResult.clientDataJSON, | |
| authenticatorData: assertionResult.authenticatorData, | |
| credentialId: assertionResult.credentialId, | |
| rpId: rp.rpId, | |
| operatorDID, | |
| deviceDisplayName: credential.displayName, | |
| assertedAt: new Date().toISOString(), | |
| }, | |
| }; | |
| return dsseSig; | |
| } | |
| // --------------------------------------------------------------------------- | |
| // DSSE envelope augmentation | |
| // --------------------------------------------------------------------------- | |
| import type { DSSEEnvelope } from "../sigstore/rekor_submit.js"; | |
| /** | |
| * Augment an existing DSSE envelope with a WebAuthn signature. | |
| * The WebAuthn signature is appended to the `signatures` array alongside | |
| * the existing HMAC / ECDSA signature — it does not replace it. | |
| */ | |
| export function augmentDSSEWithWebAuthn( | |
| envelope: DSSEEnvelope, | |
| webAuthnSig: WebAuthnDSSESignature | |
| ): DSSEEnvelope { | |
| return { | |
| ...envelope, | |
| signatures: [ | |
| ...envelope.signatures, | |
| webAuthnSig, // Additional WebAuthn attestation | |
| ], | |
| }; | |
| } | |
| /** | |
| * Receipt augmentation: add WebAuthn metadata to JSONL receipt record. | |
| */ | |
| export function augmentReceiptWithWebAuthn( | |
| receipt: Record<string, unknown>, | |
| webAuthnSig: WebAuthnDSSESignature, | |
| operatorDID: string | |
| ): Record<string, unknown> { | |
| return { | |
| ...receipt, | |
| humanAttestation: { | |
| type: "WebAuthnPasskeyAttestation", | |
| operatorDID, | |
| credentialId: webAuthnSig.webauthn.credentialId, | |
| deviceDisplayName: webAuthnSig.webauthn.deviceDisplayName, | |
| rpId: webAuthnSig.webauthn.rpId, | |
| assertedAt: webAuthnSig.webauthn.assertedAt, | |
| // Verification instructions for auditors: | |
| verificationNote: | |
| "WebAuthn assertion bound to DSSE payload hash via clientDataJSON.challenge. " + | |
| "Verify: SHA-256(envelope.payload) == base64url-decode(clientDataJSON.challenge). " + | |
| "ECDSA-P256 signature over SHA-256(authenticatorData || clientDataJSON) verifiable " + | |
| "with registered publicKey (COSE format, COSE algorithm -7 / ES256).", | |
| }, | |
| }; | |
| } | |
| // --------------------------------------------------------------------------- | |
| // Helpers | |
| // --------------------------------------------------------------------------- | |
| function generateChallenge(): string { | |
| // 32 bytes of crypto-random data — use webcrypto in prod for browser compat | |
| const bytes = Buffer.alloc(32); | |
| for (let i = 0; i < 32; i++) { | |
| bytes[i] = Math.floor(Math.random() * 256); | |
| } | |
| return encodeBase64url(bytes); | |
| } | |
| function encodeBase64url(buf: Buffer): string { | |
| return buf.toString("base64url"); | |
| } | |
| function decodeBase64url(b64: string): Buffer { | |
| return Buffer.from(b64.replace(/-/g, "+").replace(/_/g, "/"), "base64"); | |
| } | |
| // --------------------------------------------------------------------------- | |
| // Exports for MCP tool surface | |
| // --------------------------------------------------------------------------- | |
| /** | |
| * MCP tool: `webauthn_register` | |
| * Called by Cursor/Claude when an operator wants to register a passkey. | |
| * | |
| * Returns registration options JSON to embed in the browser-side prompt. | |
| * The browser then calls startRegistration() from @simplewebauthn/browser. | |
| */ | |
| export function mcpWebAuthnRegister( | |
| rp: SZLWebAuthnRP, | |
| operatorDID: string, | |
| userName: string | |
| ): RegistrationOptions { | |
| return generateRegistrationOptions(rp, operatorDID, userName, `${userName}'s Passkey`); | |
| } | |
| /** | |
| * MCP tool: `webauthn_approve` | |
| * Called for receipts requiring human approval (e.g., production deploys, key rotations). | |
| * Returns signing options; browser then calls startAuthentication() and returns assertion. | |
| */ | |
| export function mcpWebAuthnApprove( | |
| rp: SZLWebAuthnRP, | |
| operatorDID: string, | |
| dssePayloadBase64url: string | |
| ): AuthenticationOptions { | |
| const creds = Array.from(rp.credentials.values()).filter( | |
| (c) => c.operatorDID === operatorDID | |
| ); | |
| if (creds.length === 0) { | |
| throw new Error(`No registered passkeys for operator ${operatorDID}`); | |
| } | |
| return generateSigningOptions(rp, operatorDID, dssePayloadBase64url, creds); | |
| } | |