rspace-online/src/encryptid/webauthn.ts

485 lines
14 KiB
TypeScript

/**
* EncryptID WebAuthn Module
*
* Handles passkey registration, authentication, and PRF extension
* for key derivation. This is the foundation layer of EncryptID.
*/
// ============================================================================
// TYPES
// ============================================================================
export interface EncryptIDCredential {
credentialId: string;
publicKey: ArrayBuffer;
userId: string;
username: string;
createdAt: number;
prfSupported: boolean;
transports?: AuthenticatorTransport[];
}
export interface AuthenticationResult {
credentialId: string;
userId: string;
prfOutput?: ArrayBuffer; // Only if PRF extension supported
signature: ArrayBuffer;
authenticatorData: ArrayBuffer;
}
export interface EncryptIDConfig {
rpId: string;
rpName: string;
origin: string;
userVerification: UserVerificationRequirement;
timeout: number;
}
// Default configuration for EncryptID
const DEFAULT_CONFIG: EncryptIDConfig = {
rpId: 'rspace.online', // Root domain — all *.rspace.online subdomains are valid
rpName: 'EncryptID',
origin: typeof window !== 'undefined' ? window.location.origin : '',
userVerification: 'required',
timeout: 60000,
};
// Global abort controller for conditional UI
let conditionalUIAbortController: AbortController | null = null;
/**
* Abort any pending conditional UI request
* Call this before starting registration or authentication
*/
export function abortConditionalUI(): void {
if (conditionalUIAbortController) {
conditionalUIAbortController.abort();
conditionalUIAbortController = null;
console.log('EncryptID: Conditional UI aborted');
}
}
// ============================================================================
// UTILITY FUNCTIONS
// ============================================================================
/**
* Convert ArrayBuffer to base64url string
*/
export function bufferToBase64url(buffer: ArrayBuffer): string {
const bytes = new Uint8Array(buffer);
let binary = '';
for (let i = 0; i < bytes.byteLength; i++) {
binary += String.fromCharCode(bytes[i]);
}
return btoa(binary)
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=/g, '');
}
/**
* Convert base64url string to ArrayBuffer
*/
export function base64urlToBuffer(base64url: string): ArrayBuffer {
const base64 = base64url.replace(/-/g, '+').replace(/_/g, '/');
const padding = '='.repeat((4 - (base64.length % 4)) % 4);
const binary = atob(base64 + padding);
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i++) {
bytes[i] = binary.charCodeAt(i);
}
return bytes.buffer;
}
/**
* Generate a random challenge
*/
export function generateChallenge(): ArrayBuffer {
return crypto.getRandomValues(new Uint8Array(32)).buffer;
}
/**
* Generate a deterministic salt for PRF extension
*/
export async function generatePRFSalt(purpose: string): Promise<ArrayBuffer> {
const encoder = new TextEncoder();
const data = encoder.encode(`encryptid-prf-salt-${purpose}-v1`);
return crypto.subtle.digest('SHA-256', data);
}
// ============================================================================
// WEBAUTHN REGISTRATION
// ============================================================================
/**
* Register a new EncryptID passkey
*
* This creates a discoverable credential (passkey) that can be used
* for authentication across all r-ecosystem apps.
*/
export async function registerPasskey(
username: string,
displayName: string,
config: Partial<EncryptIDConfig> = {}
): Promise<EncryptIDCredential> {
// Abort any pending conditional UI to prevent "request already pending" error
abortConditionalUI();
const cfg = { ...DEFAULT_CONFIG, ...config };
// Check WebAuthn support
if (!window.PublicKeyCredential) {
throw new Error('WebAuthn is not supported in this browser');
}
// Check platform authenticator availability
const platformAvailable = await PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable();
// Generate user ID (random bytes, not PII)
const userId = crypto.getRandomValues(new Uint8Array(32));
// Generate challenge (in production, this comes from server)
const challenge = generateChallenge();
// Generate PRF salt for key derivation
const prfSalt = await generatePRFSalt('master-key');
// Build credential creation options
const createOptions: CredentialCreationOptions = {
publicKey: {
challenge: new Uint8Array(challenge),
// Relying Party information
rp: {
id: cfg.rpId,
name: cfg.rpName,
},
// User information
user: {
id: userId,
name: username,
displayName: displayName,
},
// Supported algorithms (prefer ES256)
pubKeyCredParams: [
{ alg: -7, type: 'public-key' }, // ES256 (P-256)
{ alg: -257, type: 'public-key' }, // RS256
],
// Authenticator requirements
authenticatorSelection: {
// Require discoverable credential (passkey)
residentKey: 'required',
requireResidentKey: true,
// Require user verification (biometric/PIN)
userVerification: cfg.userVerification,
// Prefer platform authenticator but allow cross-platform
authenticatorAttachment: platformAvailable ? 'platform' : undefined,
},
// Don't request attestation (privacy)
attestation: 'none',
// Timeout
timeout: cfg.timeout,
// Extensions
extensions: {
// Request PRF extension for key derivation
// @ts-ignore - PRF extension not in standard types yet
prf: {
eval: {
first: new Uint8Array(prfSalt),
},
},
// Request credential properties
credProps: true,
},
},
};
// Create the credential
const credential = await navigator.credentials.create(createOptions) as PublicKeyCredential;
if (!credential) {
throw new Error('Failed to create credential');
}
const response = credential.response as AuthenticatorAttestationResponse;
// Check if PRF is supported
// @ts-ignore
const prfSupported = credential.getClientExtensionResults()?.prf?.enabled === true;
// Extract public key from attestation
const publicKey = response.getPublicKey();
if (!publicKey) {
throw new Error('Failed to get public key from credential');
}
// Build credential object
const encryptIDCredential: EncryptIDCredential = {
credentialId: bufferToBase64url(credential.rawId),
publicKey: publicKey,
userId: bufferToBase64url(userId.buffer),
username: username,
createdAt: Date.now(),
prfSupported: prfSupported,
transports: response.getTransports?.() as AuthenticatorTransport[],
};
console.log('EncryptID: Passkey registered', {
credentialId: encryptIDCredential.credentialId.slice(0, 20) + '...',
prfSupported,
});
return encryptIDCredential;
}
// ============================================================================
// WEBAUTHN AUTHENTICATION
// ============================================================================
/**
* Authenticate with an existing EncryptID passkey
*
* Returns the authentication result including PRF output for key derivation
* (if the authenticator supports PRF).
*/
export async function authenticatePasskey(
credentialId?: string, // Optional: specify credential, or let user choose
config: Partial<EncryptIDConfig> = {}
): Promise<AuthenticationResult> {
// Abort any pending conditional UI to prevent "request already pending" error
abortConditionalUI();
const cfg = { ...DEFAULT_CONFIG, ...config };
// Check WebAuthn support
if (!window.PublicKeyCredential) {
throw new Error('WebAuthn is not supported in this browser');
}
// Generate challenge (in production, this comes from server)
const challenge = generateChallenge();
// Generate PRF salt for key derivation
const prfSalt = await generatePRFSalt('master-key');
// Build allowed credentials list
const allowCredentials: PublicKeyCredentialDescriptor[] | undefined = credentialId
? [{
type: 'public-key',
id: new Uint8Array(base64urlToBuffer(credentialId)),
}]
: undefined; // undefined = let user choose from available passkeys
// Build authentication options
const getOptions: CredentialRequestOptions = {
publicKey: {
challenge: new Uint8Array(challenge),
// Relying Party ID
rpId: cfg.rpId,
// Allowed credentials (or undefined for discoverable)
allowCredentials: allowCredentials,
// Require user verification
userVerification: cfg.userVerification,
// Timeout
timeout: cfg.timeout,
// Extensions
extensions: {
// Request PRF evaluation for key derivation
// @ts-ignore - PRF extension not in standard types yet
prf: {
eval: {
first: new Uint8Array(prfSalt),
},
},
},
},
};
// Perform authentication
const credential = await navigator.credentials.get(getOptions) as PublicKeyCredential;
if (!credential) {
throw new Error('Authentication failed');
}
const response = credential.response as AuthenticatorAssertionResponse;
// Extract PRF output if available
// @ts-ignore
const prfResults = credential.getClientExtensionResults()?.prf?.results;
const prfOutput = prfResults?.first;
// Build result
const result: AuthenticationResult = {
credentialId: bufferToBase64url(credential.rawId),
userId: response.userHandle
? bufferToBase64url(response.userHandle)
: '',
prfOutput: prfOutput as ArrayBuffer | undefined,
signature: response.signature,
authenticatorData: response.authenticatorData,
};
console.log('EncryptID: Authentication successful', {
credentialId: result.credentialId.slice(0, 20) + '...',
prfAvailable: !!prfOutput,
});
return result;
}
// ============================================================================
// CONDITIONAL UI (AUTOFILL)
// ============================================================================
/**
* Check if conditional mediation (passkey autofill) is available
*/
export async function isConditionalMediationAvailable(): Promise<boolean> {
if (!window.PublicKeyCredential) {
return false;
}
// @ts-ignore
if (typeof PublicKeyCredential.isConditionalMediationAvailable === 'function') {
// @ts-ignore
return PublicKeyCredential.isConditionalMediationAvailable();
}
return false;
}
/**
* Start conditional UI authentication (passkey autofill in input fields)
*
* Call this on page load to enable passkey suggestions in username fields.
* The input field needs `autocomplete="username webauthn"` attribute.
*/
export async function startConditionalUI(
config: Partial<EncryptIDConfig> = {}
): Promise<AuthenticationResult | null> {
const available = await isConditionalMediationAvailable();
if (!available) {
console.log('EncryptID: Conditional mediation not available');
return null;
}
// Abort any existing conditional UI request
abortConditionalUI();
// Create new abort controller for this request
conditionalUIAbortController = new AbortController();
const cfg = { ...DEFAULT_CONFIG, ...config };
const challenge = generateChallenge();
const prfSalt = await generatePRFSalt('master-key');
try {
const credential = await navigator.credentials.get({
publicKey: {
challenge: new Uint8Array(challenge),
rpId: cfg.rpId,
userVerification: cfg.userVerification,
timeout: cfg.timeout,
extensions: {
// @ts-ignore
prf: {
eval: {
first: new Uint8Array(prfSalt),
},
},
},
},
// @ts-ignore - conditional mediation
mediation: 'conditional',
signal: conditionalUIAbortController.signal,
}) as PublicKeyCredential;
// Clear abort controller on success
conditionalUIAbortController = null;
if (!credential) {
return null;
}
const response = credential.response as AuthenticatorAssertionResponse;
// @ts-ignore
const prfResults = credential.getClientExtensionResults()?.prf?.results;
return {
credentialId: bufferToBase64url(credential.rawId),
userId: response.userHandle
? bufferToBase64url(response.userHandle)
: '',
prfOutput: prfResults?.first as ArrayBuffer | undefined,
signature: response.signature,
authenticatorData: response.authenticatorData,
};
} catch (error) {
// Conditional UI was cancelled or failed
console.log('EncryptID: Conditional UI cancelled or failed', error);
return null;
}
}
// ============================================================================
// FEATURE DETECTION
// ============================================================================
export interface WebAuthnCapabilities {
webauthn: boolean;
platformAuthenticator: boolean;
conditionalUI: boolean;
prfExtension: boolean; // Note: Can only be confirmed after credential creation
}
/**
* Detect WebAuthn capabilities of the current browser/device
*/
export async function detectCapabilities(): Promise<WebAuthnCapabilities> {
const capabilities: WebAuthnCapabilities = {
webauthn: false,
platformAuthenticator: false,
conditionalUI: false,
prfExtension: false,
};
// Check basic WebAuthn support
if (!window.PublicKeyCredential) {
return capabilities;
}
capabilities.webauthn = true;
// Check platform authenticator
try {
capabilities.platformAuthenticator =
await PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable();
} catch {
capabilities.platformAuthenticator = false;
}
// Check conditional UI
capabilities.conditionalUI = await isConditionalMediationAvailable();
// PRF support can only be confirmed after credential creation
// We assume true for modern browsers and verify during registration
capabilities.prfExtension = true; // Optimistic, verified at registration
return capabilities;
}