@cognitiveproof/webauthn-prf-identity
v0.3.0
Published
Derive a per-profile Ed25519 identity key in the browser from a WebAuthn PRF extension output, and compute the did:jwk issuer identifier expected by c2pa-rs-javascript-library's ICA signing path.
Readme
@cognitiveproof/webauthn-prf-identity
Derives a per-profile Ed25519 identity key in the browser from a WebAuthn
credential's PRF (pseudo-random function) extension output, and computes the
did:jwk issuer identifier expected by
c2pa-rs-javascript-library's ICA
(Identity Claims Aggregation) signing path.
The raw Ed25519 seed never leaves the browser and is never persisted — it's re-derived fresh from the authenticator each time a profile needs to sign something (PRF output for a given credential + salt is deterministic). Only the credential id, the (non-secret) salt, and the resulting DID are ever sent anywhere else.
Ships with a "use client" directive so it can be imported directly into
Next.js App Router client components without consumers needing to add the
pragma themselves.
Install
npm install @cognitiveproof/webauthn-prf-identityRequires a browser with WebAuthn PRF extension support (e.g. Chrome/Edge with a platform authenticator, or a PRF-capable security key).
Usage
Every function that touches key material takes a WebAuthnPrfIdentityConfig
— set hkdfInfo to something unique to your application. It's the HKDF
domain-separation label between the raw PRF output and the derived Ed25519
seed, so two applications that shared a label (and somehow shared a
credential + salt pair) would derive the same key.
import {
isWebAuthnSupported,
registerWebAuthnPrfCredential,
deriveSigningSeed,
signBytes,
computeIcaIssuerDidFromPublicKey,
PrfNotSupportedError,
} from "@cognitiveproof/webauthn-prf-identity";
const config = {
rpName: "My App",
hkdfInfo: "my-app-ica-v1",
};
// Once per profile, during onboarding:
if (isWebAuthnSupported()) {
try {
const { credentialId, prfSalt, publicKey, issuerDid } =
await registerWebAuthnPrfCredential(config, profileId, displayName);
// Persist credentialId, prfSalt, publicKey, and issuerDid server-side.
// The Ed25519 seed itself is never returned or stored.
} catch (err) {
if (err instanceof PrfNotSupportedError) {
// Fall back to whatever non-client-side signing path your app uses.
}
}
}
// Later, at signing time (credential = { credentialId, prfSalt } loaded back
// from wherever you persisted it above):
const signature = await signBytes(config, credential, toSign);API
isWebAuthnSupported(): boolean— whether the browser exposes the WebAuthn API at all. Whether the specific authenticator supports the PRF extension is only known after attempting registration.registerWebAuthnPrfCredential(config, profileId, profileDisplayName): Promise<WebAuthnRegistrationResult>— registers a new credential and returns{ credentialId, prfSalt, publicKey, issuerDid }.publicKeyis the base64url-encoded raw 32-byte Ed25519 public key, given directly so you don't have to decode it back out ofissuerDid. On most authenticators this fires two native WebAuthn prompts back to back (one to create the credential, one to evaluate PRF — seebeginWebAuthnPrfRegistrationbelow for why) with no app UI in between. If you want to explain the second prompt to the user first, call the two steps yourself instead of this all-in-one convenience wrapper.beginWebAuthnPrfRegistration(config, profileId, profileDisplayName): Promise<{ credentialId: string }>— step 1 of registration: creates the WebAuthn credential (first native prompt) and confirms the authenticator supports PRF. Doesn't evaluate PRF yet, so it doesn't return key material.finishWebAuthnPrfRegistration(config, credentialId): Promise<WebAuthnRegistrationResult>— step 2: evaluates PRF for the credentialbeginWebAuthnPrfRegistrationjust created (second native prompt — most authenticators can't return a usable PRF secret during creation itself, only on a follow-up assertion) and derives the Ed25519 key and DID from it. Call this only afterbeginWebAuthnPrfRegistrationhas resolved for the same credential.deriveSigningSeed(config, credential): Promise<Uint8Array>— re-derives the raw 32-byte Ed25519 seed for an already-registered credential. Prompts the authenticator; that prompt is the user's consent.signBytes(config, credential, toSign): Promise<Uint8Array>— signs arbitrary bytes with the profile's device-derived key and returns a 64-byte raw (RFC 8032, R||S) signature.signIcaToSign(config, credential, toSign): Promise<Uint8Array>— alias forsignBytes, named for c2pa-rs-javascript-library's ICA (Identity Claims Aggregation) signing path: signs bytes frommanifest.prepareIcaSigningand returns whatmanifest.finalizeIcaSigningexpects.computeIcaIssuerDidFromPublicKey(publicKey): string—did:jwkencoding for an Ed25519 public key, matching c2pa-rs-javascript-library's owncomputeIcaIssuerDid(). Pure function, no config needed.PrfNotSupportedError— thrown when the authenticator/browser combination doesn't support the PRF extension.
Development
npm install
npm run build # tsup -> dist/{index.js,index.cjs,index.d.ts}
npm run typecheck
npm run playground # builds, then serves playground/index.html for manual testingplayground/ is a manual test page for exercising real WebAuthn prompts in
a real browser (this can't be automated headlessly) — it's excluded from the
published package via package.json's files field.
