diff --git a/src/encryptid/key-derivation.ts b/src/encryptid/key-derivation.ts index 38f7cf9..5a4a721 100644 --- a/src/encryptid/key-derivation.ts +++ b/src/encryptid/key-derivation.ts @@ -7,6 +7,8 @@ import { bufferToBase64url, base64urlToBuffer } from './webauthn'; import { deriveEOAFromPRF } from './eoa-derivation'; +import { p256 } from '@noble/curves/p256'; +import { ed25519 } from '@noble/curves/ed25519'; // ============================================================================ // TYPES @@ -243,23 +245,30 @@ export class EncryptIDKeyManager { 256 ); - // For now, generate a non-deterministic key pair - // TODO: Use @noble/curves for deterministic generation from seed - // This is a placeholder - in production, use the seed deterministically - const keyPair = await crypto.subtle.generateKey( - { - name: 'ECDSA', - namedCurve: 'P-256', - }, - false, // Private key not extractable - ['sign', 'verify'] + // Derive deterministic P-256 key pair from seed using @noble/curves + const seedBytes = new Uint8Array(seed); + const publicKeyUncompressed = p256.getPublicKey(seedBytes, false); // 65 bytes: 04 || x || y + + // Import into WebCrypto as ECDSA key pair for sign/verify operations + // PKCS8 wrapping for private key import + const pkcs8 = buildP256Pkcs8(seedBytes); + const privateKey = await crypto.subtle.importKey( + 'pkcs8', + pkcs8, + { name: 'ECDSA', namedCurve: 'P-256' }, + false, // Not extractable + ['sign'] ); - // Store seed hash for verification - console.log('EncryptID: Signing key derived (seed hash):', - bufferToBase64url(await crypto.subtle.digest('SHA-256', seed)).slice(0, 16)); + const publicKey = await crypto.subtle.importKey( + 'raw', + publicKeyUncompressed as BufferSource, + { name: 'ECDSA', namedCurve: 'P-256' }, + true, + ['verify'] + ); - return keyPair; + return { privateKey, publicKey }; } /** @@ -292,25 +301,17 @@ export class EncryptIDKeyManager { * Format: did:key:z6Mk... (multicodec ed25519-pub + base58btc) */ private async generateDID(seed: Uint8Array): Promise { - // Ed25519 public key generation would go here - // For now, we'll create a placeholder using the seed hash - // TODO: Use @noble/ed25519 for proper Ed25519 key generation - - const publicKeyHash = await crypto.subtle.digest('SHA-256', seed as BufferSource); - const publicKeyBytes = new Uint8Array(publicKeyHash).slice(0, 32); + // Derive Ed25519 public key from seed using @noble/curves + const publicKeyBytes = ed25519.getPublicKey(seed); // Multicodec prefix for Ed25519 public key: 0xed01 - const multicodecPrefix = new Uint8Array([0xed, 0x01]); const multicodecKey = new Uint8Array(34); - multicodecKey.set(multicodecPrefix); + multicodecKey[0] = 0xed; + multicodecKey[1] = 0x01; multicodecKey.set(publicKeyBytes, 2); - // Base58btc encode (simplified - use a proper library in production) - const base58Encoded = bufferToBase64url(multicodecKey.buffer) - .replace(/-/g, '') - .replace(/_/g, ''); - - return `did:key:z${base58Encoded}`; + // Encode as base58btc (did:key spec requires 'z' prefix + base58btc) + return `did:key:z${base58btcEncode(multicodecKey)}`; } /** @@ -536,6 +537,70 @@ export async function unwrapSharedKey( ); } +// ============================================================================ +// HELPERS +// ============================================================================ + +/** + * Build a PKCS#8 DER wrapper around a raw P-256 private key (32 bytes) + * so it can be imported via WebCrypto's importKey('pkcs8', ...). + * + * Structure: SEQUENCE { version, AlgorithmIdentifier { ecPublicKey, P-256 }, OCTET STRING { SEQUENCE { version, privateKey } } } + */ +function buildP256Pkcs8(rawPrivateKey: Uint8Array): ArrayBuffer { + // DER-encoded PKCS#8 template for P-256 with a 32-byte private key + // Pre-computed ASN.1 header (version + algorithm identifier) + const header = new Uint8Array([ + 0x30, 0x41, // SEQUENCE (65 bytes total) + 0x02, 0x01, 0x00, // INTEGER version = 0 + 0x30, 0x13, // SEQUENCE (AlgorithmIdentifier) + 0x06, 0x07, // OID 1.2.840.10045.2.1 (ecPublicKey) + 0x2a, 0x86, 0x48, 0xce, 0x3d, 0x02, 0x01, + 0x06, 0x08, // OID 1.2.840.10045.3.1.7 (P-256) + 0x2a, 0x86, 0x48, 0xce, 0x3d, 0x03, 0x01, 0x07, + 0x04, 0x27, // OCTET STRING (39 bytes) + 0x30, 0x25, // SEQUENCE (37 bytes) + 0x02, 0x01, 0x01, // INTEGER version = 1 + 0x04, 0x20, // OCTET STRING (32 bytes) — the private key + ]); + + const result = new Uint8Array(header.length + 32); + result.set(header); + result.set(rawPrivateKey, header.length); + return result.buffer; +} + +/** + * Base58btc encoding (Bitcoin alphabet). + * Minimal implementation — no external dependency needed. + */ +const BASE58_ALPHABET = '123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz'; + +function base58btcEncode(bytes: Uint8Array): string { + // Count leading zeros + let zeroes = 0; + for (const b of bytes) { + if (b !== 0) break; + zeroes++; + } + + // Convert to base58 using bigint arithmetic + let num = BigInt(0); + for (const b of bytes) { + num = num * 256n + BigInt(b); + } + + let encoded = ''; + while (num > 0n) { + const remainder = Number(num % 58n); + num = num / 58n; + encoded = BASE58_ALPHABET[remainder] + encoded; + } + + // Prepend '1' for each leading zero byte + return '1'.repeat(zeroes) + encoded; +} + // ============================================================================ // SINGLETON INSTANCE // ============================================================================ diff --git a/src/encryptid/recovery.ts b/src/encryptid/recovery.ts index 4a61110..ba1d2d5 100644 --- a/src/encryptid/recovery.ts +++ b/src/encryptid/recovery.ts @@ -106,13 +106,34 @@ export interface RecoveryRequest { export class RecoveryManager { private config: RecoveryConfig | null = null; private activeRequest: RecoveryRequest | null = null; + private loaded = false; constructor() { - this.loadConfig(); + this.loadLocalSettings(); + } + + /** Get the auth token for server API calls */ + private getAuthToken(): string | null { + try { + return localStorage.getItem('encryptid_token'); + } catch { + return null; + } + } + + /** Resolve EncryptID API base URL */ + private getApiBase(): string { + // When running on the encryptid domain, use relative paths + if (typeof location !== 'undefined' && location.hostname.includes('auth.')) { + return ''; + } + // From rspace canvas, reach encryptid via its public URL + return 'https://auth.rspace.online'; } /** - * Initialize recovery with default configuration + * Initialize recovery with default configuration. + * Fetches existing guardians from server if authenticated. */ async initializeRecovery(threshold: number = 3): Promise { this.config = { @@ -123,25 +144,69 @@ export class RecoveryManager { updatedAt: Date.now(), }; - await this.saveConfig(); + // Try to load existing guardians from server + await this.syncFromServer(); + + await this.saveLocalSettings(); return this.config; } /** - * Add a guardian + * Ensure config is loaded (call before operations that need config) + */ + async ensureLoaded(): Promise { + if (this.loaded) return; + await this.syncFromServer(); + this.loaded = true; + } + + /** + * Add a guardian — persists to server API (which sends invite email). + * Falls back to local-only if not authenticated. */ async addGuardian(guardian: Omit): Promise { if (!this.config) { throw new Error('Recovery not initialized'); } - if (this.config.guardians.length >= 7) { - throw new Error('Maximum of 7 guardians allowed'); + if (this.config.guardians.length >= 3) { + throw new Error('Maximum of 3 guardians allowed. Remove one first.'); + } + + const token = this.getAuthToken(); + let serverId: string | undefined; + + // POST to server — creates DB row + sends invite email + if (token) { + const res = await fetch(`${this.getApiBase()}/api/guardians`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${token}`, + }, + body: JSON.stringify({ + name: guardian.name, + email: guardian.contactEmail || undefined, + }), + }); + + if (!res.ok) { + const err = await res.json().catch(() => ({ error: 'Server error' })); + throw new Error(err.error || `Server error: ${res.status}`); + } + + const data = await res.json(); + serverId = data.guardian?.id; + + console.log('EncryptID: Guardian added via server', { + id: serverId, + inviteUrl: data.inviteUrl, + }); } const newGuardian: Guardian = { ...guardian, - id: bufferToBase64url(crypto.getRandomValues(new Uint8Array(16)).buffer), + id: serverId || bufferToBase64url(crypto.getRandomValues(new Uint8Array(16)).buffer), addedAt: Date.now(), }; @@ -149,7 +214,7 @@ export class RecoveryManager { this.config.guardianListHash = await this.hashGuardianList(); this.config.updatedAt = Date.now(); - await this.saveConfig(); + await this.saveLocalSettings(); console.log('EncryptID: Guardian added', { type: GuardianType[guardian.type], @@ -160,7 +225,7 @@ export class RecoveryManager { } /** - * Remove a guardian + * Remove a guardian — deletes from server API and local config. */ async removeGuardian(guardianId: string): Promise { if (!this.config) { @@ -172,20 +237,25 @@ export class RecoveryManager { throw new Error('Guardian not found'); } - // Don't allow removing if it would make recovery impossible - const remainingWeight = this.config.guardians - .filter(g => g.id !== guardianId) - .reduce((sum, g) => sum + g.weight, 0); + // DELETE from server + const token = this.getAuthToken(); + if (token) { + const res = await fetch(`${this.getApiBase()}/api/guardians/${guardianId}`, { + method: 'DELETE', + headers: { 'Authorization': `Bearer ${token}` }, + }); - if (remainingWeight < this.config.threshold) { - throw new Error('Cannot remove guardian: would make recovery impossible'); + if (!res.ok && res.status !== 404) { + const err = await res.json().catch(() => ({ error: 'Server error' })); + throw new Error(err.error || `Server error: ${res.status}`); + } } this.config.guardians.splice(index, 1); this.config.guardianListHash = await this.hashGuardianList(); this.config.updatedAt = Date.now(); - await this.saveConfig(); + await this.saveLocalSettings(); console.log('EncryptID: Guardian removed'); } @@ -210,7 +280,7 @@ export class RecoveryManager { this.config.threshold = threshold; this.config.updatedAt = Date.now(); - await this.saveConfig(); + await this.saveLocalSettings(); console.log('EncryptID: Threshold updated to', threshold); } @@ -231,7 +301,7 @@ export class RecoveryManager { this.config.delaySeconds = delaySeconds; this.config.updatedAt = Date.now(); - await this.saveConfig(); + await this.saveLocalSettings(); console.log('EncryptID: Delay updated to', delaySeconds, 'seconds'); } @@ -254,7 +324,8 @@ export class RecoveryManager { } /** - * Verify a guardian is still reachable/valid + * Verify a guardian is still reachable/valid. + * Checks server for current guardian status (pending/accepted). */ async verifyGuardian(guardianId: string): Promise { if (!this.config) { @@ -266,15 +337,39 @@ export class RecoveryManager { throw new Error('Guardian not found'); } - // In production, this would: - // - For SECONDARY_PASSKEY: verify credential still exists - // - For TRUSTED_CONTACT: send verification request - // - For INSTITUTIONAL: ping service endpoint - // - For HARDWARE_KEY: request signature + const token = this.getAuthToken(); + if (token) { + // Fetch current guardian list from server to check status + const res = await fetch(`${this.getApiBase()}/api/guardians`, { + headers: { 'Authorization': `Bearer ${token}` }, + }); + if (res.ok) { + const data = await res.json(); + const serverGuardian = data.guardians?.find((g: any) => g.id === guardianId); + + if (!serverGuardian) { + throw new Error('Guardian not found on server — may have been removed'); + } + + // Check acceptance status + if (serverGuardian.status === 'accepted') { + guardian.lastVerified = Date.now(); + await this.saveLocalSettings(); + return true; + } + + // Still pending — guardian hasn't accepted invite yet + if (serverGuardian.status === 'pending') { + console.log('EncryptID: Guardian invite still pending', { guardianId }); + return false; + } + } + } + + // Fallback: mark as verified locally (offline mode) guardian.lastVerified = Date.now(); - await this.saveConfig(); - + await this.saveLocalSettings(); return true; } @@ -458,9 +553,11 @@ export class RecoveryManager { } /** - * Save configuration to storage + * Save local settings (threshold, delay) to localStorage. + * Guardian CRUD goes through the server API — this only persists + * config that the server doesn't track (threshold, delay, type metadata). */ - private async saveConfig(): Promise { + private async saveLocalSettings(): Promise { if (!this.config) return; try { @@ -471,9 +568,10 @@ export class RecoveryManager { } /** - * Load configuration from storage + * Load local settings from localStorage (synchronous, for constructor). + * Call syncFromServer() after construction for full server sync. */ - private loadConfig(): void { + private loadLocalSettings(): void { try { const stored = localStorage.getItem('encryptid_recovery'); if (stored) { @@ -485,23 +583,87 @@ export class RecoveryManager { } /** - * Notify user of recovery events + * Sync guardian list from server API into local config. + * Maps server guardian model to client Guardian type. + */ + private async syncFromServer(): Promise { + const token = this.getAuthToken(); + if (!token) return; + + try { + const res = await fetch(`${this.getApiBase()}/api/guardians`, { + headers: { 'Authorization': `Bearer ${token}` }, + }); + + if (!res.ok) return; + + const data = await res.json(); + if (!data.guardians || !Array.isArray(data.guardians)) return; + + if (!this.config) { + this.config = { + threshold: data.threshold || 2, + delaySeconds: 48 * 60 * 60, + guardians: [], + guardianListHash: '', + updatedAt: Date.now(), + }; + } + + // Build a map of existing local guardians for metadata preservation + const localMap = new Map(this.config.guardians.map(g => [g.id, g])); + + // Merge server guardians with local type metadata + this.config.guardians = data.guardians.map((sg: any) => { + const local = localMap.get(sg.id); + return { + id: sg.id, + type: local?.type ?? GuardianType.TRUSTED_CONTACT, + name: sg.name, + weight: local?.weight ?? 1, + contactEmail: sg.email || local?.contactEmail, + addedAt: sg.createdAt ? new Date(sg.createdAt).getTime() : (local?.addedAt ?? Date.now()), + lastVerified: sg.status === 'accepted' ? (sg.acceptedAt ? new Date(sg.acceptedAt).getTime() : Date.now()) : local?.lastVerified, + // Preserve type-specific fields from local + credentialId: local?.credentialId, + contactDID: local?.contactDID, + serviceUrl: local?.serviceUrl, + delaySeconds: local?.delaySeconds, + } satisfies Guardian; + }); + + this.config.guardianListHash = await this.hashGuardianList(); + this.config.updatedAt = Date.now(); + + await this.saveLocalSettings(); + + console.log('EncryptID: Synced guardians from server', { + count: this.config.guardians.length, + }); + } catch (error) { + console.warn('EncryptID: Failed to sync guardians from server', error); + } + } + + /** + * Notify user of recovery events via CustomEvent on document. */ private async notifyUser( event: string, request: RecoveryRequest ): Promise { - // In production, this would: - // - Send email notification - // - Send push notification - // - Send SMS (if configured) - // - Post to webhook - console.log('EncryptID: User notification', { event, requestId: request.id }); + + if (typeof document !== 'undefined') { + document.dispatchEvent(new CustomEvent('encryptid:recovery', { + detail: { event, requestId: request.id, status: request.status }, + })); + } } /** - * Notify guardians of recovery events + * Notify guardians of recovery events via CustomEvent on document. + * Server-side email notifications are handled by the guardian invite flow. */ private async notifyGuardians( event: string, @@ -509,13 +671,19 @@ export class RecoveryManager { ): Promise { if (!this.config) return; - // In production, this would notify each guardian through their preferred channel - for (const guardian of this.config.guardians) { - console.log('EncryptID: Guardian notification', { - event, - guardianId: guardian.id, - guardianType: GuardianType[guardian.type], - }); + console.log('EncryptID: Guardian notification', { + event, + guardianCount: this.config.guardians.length, + }); + + if (typeof document !== 'undefined') { + document.dispatchEvent(new CustomEvent('encryptid:guardian-notification', { + detail: { + event, + requestId: request.id, + guardianIds: this.config.guardians.map(g => g.id), + }, + })); } } } diff --git a/src/encryptid/ui/guardian-setup.ts b/src/encryptid/ui/guardian-setup.ts index 70ad83c..388effb 100644 --- a/src/encryptid/ui/guardian-setup.ts +++ b/src/encryptid/ui/guardian-setup.ts @@ -449,20 +449,22 @@ export class GuardianSetupElement extends HTMLElement { connectedCallback() { this.loadConfig(); - this.render(); } - private loadConfig() { + private async loadConfig() { const manager = getRecoveryManager(); + + // Ensure server sync is complete before reading config + await manager.ensureLoaded(); this.config = manager.getConfig(); // Initialize if not configured if (!this.config) { - manager.initializeRecovery(3).then(() => { - this.config = manager.getConfig(); - this.render(); - }); + await manager.initializeRecovery(3); + this.config = manager.getConfig(); } + + this.render(); } private render() {