485 lines
14 KiB
TypeScript
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;
|
|
}
|