fix(encryptid): deterministic key derivation and server-backed guardian recovery
- Key derivation: replace random crypto.subtle.generateKey with deterministic P-256 via @noble/curves/p256 and real Ed25519 did:key generation via @noble/curves/ed25519 with multicodec prefix + base58btc encoding - Guardian recovery: wire RecoveryManager to server API (GET/POST/DELETE /api/guardians) instead of localStorage-only persistence. Server handles invite emails, client syncs guardian list on load and merges with local type metadata. verifyGuardian checks actual server acceptance status. - Notifications dispatch CustomEvents on document for UI integration - GuardianSetupElement awaits server sync before first render Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
21b31c43c7
commit
e207b18adf
|
|
@ -7,6 +7,8 @@
|
||||||
|
|
||||||
import { bufferToBase64url, base64urlToBuffer } from './webauthn';
|
import { bufferToBase64url, base64urlToBuffer } from './webauthn';
|
||||||
import { deriveEOAFromPRF } from './eoa-derivation';
|
import { deriveEOAFromPRF } from './eoa-derivation';
|
||||||
|
import { p256 } from '@noble/curves/p256';
|
||||||
|
import { ed25519 } from '@noble/curves/ed25519';
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// TYPES
|
// TYPES
|
||||||
|
|
@ -243,23 +245,30 @@ export class EncryptIDKeyManager {
|
||||||
256
|
256
|
||||||
);
|
);
|
||||||
|
|
||||||
// For now, generate a non-deterministic key pair
|
// Derive deterministic P-256 key pair from seed using @noble/curves
|
||||||
// TODO: Use @noble/curves for deterministic generation from seed
|
const seedBytes = new Uint8Array(seed);
|
||||||
// This is a placeholder - in production, use the seed deterministically
|
const publicKeyUncompressed = p256.getPublicKey(seedBytes, false); // 65 bytes: 04 || x || y
|
||||||
const keyPair = await crypto.subtle.generateKey(
|
|
||||||
{
|
// Import into WebCrypto as ECDSA key pair for sign/verify operations
|
||||||
name: 'ECDSA',
|
// PKCS8 wrapping for private key import
|
||||||
namedCurve: 'P-256',
|
const pkcs8 = buildP256Pkcs8(seedBytes);
|
||||||
},
|
const privateKey = await crypto.subtle.importKey(
|
||||||
false, // Private key not extractable
|
'pkcs8',
|
||||||
['sign', 'verify']
|
pkcs8,
|
||||||
|
{ name: 'ECDSA', namedCurve: 'P-256' },
|
||||||
|
false, // Not extractable
|
||||||
|
['sign']
|
||||||
);
|
);
|
||||||
|
|
||||||
// Store seed hash for verification
|
const publicKey = await crypto.subtle.importKey(
|
||||||
console.log('EncryptID: Signing key derived (seed hash):',
|
'raw',
|
||||||
bufferToBase64url(await crypto.subtle.digest('SHA-256', seed)).slice(0, 16));
|
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)
|
* Format: did:key:z6Mk... (multicodec ed25519-pub + base58btc)
|
||||||
*/
|
*/
|
||||||
private async generateDID(seed: Uint8Array): Promise<string> {
|
private async generateDID(seed: Uint8Array): Promise<string> {
|
||||||
// Ed25519 public key generation would go here
|
// Derive Ed25519 public key from seed using @noble/curves
|
||||||
// For now, we'll create a placeholder using the seed hash
|
const publicKeyBytes = ed25519.getPublicKey(seed);
|
||||||
// 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);
|
|
||||||
|
|
||||||
// Multicodec prefix for Ed25519 public key: 0xed01
|
// Multicodec prefix for Ed25519 public key: 0xed01
|
||||||
const multicodecPrefix = new Uint8Array([0xed, 0x01]);
|
|
||||||
const multicodecKey = new Uint8Array(34);
|
const multicodecKey = new Uint8Array(34);
|
||||||
multicodecKey.set(multicodecPrefix);
|
multicodecKey[0] = 0xed;
|
||||||
|
multicodecKey[1] = 0x01;
|
||||||
multicodecKey.set(publicKeyBytes, 2);
|
multicodecKey.set(publicKeyBytes, 2);
|
||||||
|
|
||||||
// Base58btc encode (simplified - use a proper library in production)
|
// Encode as base58btc (did:key spec requires 'z' prefix + base58btc)
|
||||||
const base58Encoded = bufferToBase64url(multicodecKey.buffer)
|
return `did:key:z${base58btcEncode(multicodecKey)}`;
|
||||||
.replace(/-/g, '')
|
|
||||||
.replace(/_/g, '');
|
|
||||||
|
|
||||||
return `did:key:z${base58Encoded}`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -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
|
// SINGLETON INSTANCE
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
|
||||||
|
|
@ -106,13 +106,34 @@ export interface RecoveryRequest {
|
||||||
export class RecoveryManager {
|
export class RecoveryManager {
|
||||||
private config: RecoveryConfig | null = null;
|
private config: RecoveryConfig | null = null;
|
||||||
private activeRequest: RecoveryRequest | null = null;
|
private activeRequest: RecoveryRequest | null = null;
|
||||||
|
private loaded = false;
|
||||||
|
|
||||||
constructor() {
|
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<RecoveryConfig> {
|
async initializeRecovery(threshold: number = 3): Promise<RecoveryConfig> {
|
||||||
this.config = {
|
this.config = {
|
||||||
|
|
@ -123,25 +144,69 @@ export class RecoveryManager {
|
||||||
updatedAt: Date.now(),
|
updatedAt: Date.now(),
|
||||||
};
|
};
|
||||||
|
|
||||||
await this.saveConfig();
|
// Try to load existing guardians from server
|
||||||
|
await this.syncFromServer();
|
||||||
|
|
||||||
|
await this.saveLocalSettings();
|
||||||
return this.config;
|
return this.config;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Add a guardian
|
* Ensure config is loaded (call before operations that need config)
|
||||||
|
*/
|
||||||
|
async ensureLoaded(): Promise<void> {
|
||||||
|
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<Guardian, 'id' | 'addedAt'>): Promise<Guardian> {
|
async addGuardian(guardian: Omit<Guardian, 'id' | 'addedAt'>): Promise<Guardian> {
|
||||||
if (!this.config) {
|
if (!this.config) {
|
||||||
throw new Error('Recovery not initialized');
|
throw new Error('Recovery not initialized');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.config.guardians.length >= 7) {
|
if (this.config.guardians.length >= 3) {
|
||||||
throw new Error('Maximum of 7 guardians allowed');
|
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 = {
|
const newGuardian: Guardian = {
|
||||||
...guardian,
|
...guardian,
|
||||||
id: bufferToBase64url(crypto.getRandomValues(new Uint8Array(16)).buffer),
|
id: serverId || bufferToBase64url(crypto.getRandomValues(new Uint8Array(16)).buffer),
|
||||||
addedAt: Date.now(),
|
addedAt: Date.now(),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -149,7 +214,7 @@ export class RecoveryManager {
|
||||||
this.config.guardianListHash = await this.hashGuardianList();
|
this.config.guardianListHash = await this.hashGuardianList();
|
||||||
this.config.updatedAt = Date.now();
|
this.config.updatedAt = Date.now();
|
||||||
|
|
||||||
await this.saveConfig();
|
await this.saveLocalSettings();
|
||||||
|
|
||||||
console.log('EncryptID: Guardian added', {
|
console.log('EncryptID: Guardian added', {
|
||||||
type: GuardianType[guardian.type],
|
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<void> {
|
async removeGuardian(guardianId: string): Promise<void> {
|
||||||
if (!this.config) {
|
if (!this.config) {
|
||||||
|
|
@ -172,20 +237,25 @@ export class RecoveryManager {
|
||||||
throw new Error('Guardian not found');
|
throw new Error('Guardian not found');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Don't allow removing if it would make recovery impossible
|
// DELETE from server
|
||||||
const remainingWeight = this.config.guardians
|
const token = this.getAuthToken();
|
||||||
.filter(g => g.id !== guardianId)
|
if (token) {
|
||||||
.reduce((sum, g) => sum + g.weight, 0);
|
const res = await fetch(`${this.getApiBase()}/api/guardians/${guardianId}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: { 'Authorization': `Bearer ${token}` },
|
||||||
|
});
|
||||||
|
|
||||||
if (remainingWeight < this.config.threshold) {
|
if (!res.ok && res.status !== 404) {
|
||||||
throw new Error('Cannot remove guardian: would make recovery impossible');
|
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.guardians.splice(index, 1);
|
||||||
this.config.guardianListHash = await this.hashGuardianList();
|
this.config.guardianListHash = await this.hashGuardianList();
|
||||||
this.config.updatedAt = Date.now();
|
this.config.updatedAt = Date.now();
|
||||||
|
|
||||||
await this.saveConfig();
|
await this.saveLocalSettings();
|
||||||
|
|
||||||
console.log('EncryptID: Guardian removed');
|
console.log('EncryptID: Guardian removed');
|
||||||
}
|
}
|
||||||
|
|
@ -210,7 +280,7 @@ export class RecoveryManager {
|
||||||
this.config.threshold = threshold;
|
this.config.threshold = threshold;
|
||||||
this.config.updatedAt = Date.now();
|
this.config.updatedAt = Date.now();
|
||||||
|
|
||||||
await this.saveConfig();
|
await this.saveLocalSettings();
|
||||||
|
|
||||||
console.log('EncryptID: Threshold updated to', threshold);
|
console.log('EncryptID: Threshold updated to', threshold);
|
||||||
}
|
}
|
||||||
|
|
@ -231,7 +301,7 @@ export class RecoveryManager {
|
||||||
this.config.delaySeconds = delaySeconds;
|
this.config.delaySeconds = delaySeconds;
|
||||||
this.config.updatedAt = Date.now();
|
this.config.updatedAt = Date.now();
|
||||||
|
|
||||||
await this.saveConfig();
|
await this.saveLocalSettings();
|
||||||
|
|
||||||
console.log('EncryptID: Delay updated to', delaySeconds, 'seconds');
|
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<boolean> {
|
async verifyGuardian(guardianId: string): Promise<boolean> {
|
||||||
if (!this.config) {
|
if (!this.config) {
|
||||||
|
|
@ -266,15 +337,39 @@ export class RecoveryManager {
|
||||||
throw new Error('Guardian not found');
|
throw new Error('Guardian not found');
|
||||||
}
|
}
|
||||||
|
|
||||||
// In production, this would:
|
const token = this.getAuthToken();
|
||||||
// - For SECONDARY_PASSKEY: verify credential still exists
|
if (token) {
|
||||||
// - For TRUSTED_CONTACT: send verification request
|
// Fetch current guardian list from server to check status
|
||||||
// - For INSTITUTIONAL: ping service endpoint
|
const res = await fetch(`${this.getApiBase()}/api/guardians`, {
|
||||||
// - For HARDWARE_KEY: request signature
|
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();
|
guardian.lastVerified = Date.now();
|
||||||
await this.saveConfig();
|
await this.saveLocalSettings();
|
||||||
|
|
||||||
return true;
|
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<void> {
|
private async saveLocalSettings(): Promise<void> {
|
||||||
if (!this.config) return;
|
if (!this.config) return;
|
||||||
|
|
||||||
try {
|
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 {
|
try {
|
||||||
const stored = localStorage.getItem('encryptid_recovery');
|
const stored = localStorage.getItem('encryptid_recovery');
|
||||||
if (stored) {
|
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<void> {
|
||||||
|
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(
|
private async notifyUser(
|
||||||
event: string,
|
event: string,
|
||||||
request: RecoveryRequest
|
request: RecoveryRequest
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
// 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 });
|
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(
|
private async notifyGuardians(
|
||||||
event: string,
|
event: string,
|
||||||
|
|
@ -509,13 +671,19 @@ export class RecoveryManager {
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
if (!this.config) return;
|
if (!this.config) return;
|
||||||
|
|
||||||
// In production, this would notify each guardian through their preferred channel
|
console.log('EncryptID: Guardian notification', {
|
||||||
for (const guardian of this.config.guardians) {
|
event,
|
||||||
console.log('EncryptID: Guardian notification', {
|
guardianCount: this.config.guardians.length,
|
||||||
event,
|
});
|
||||||
guardianId: guardian.id,
|
|
||||||
guardianType: GuardianType[guardian.type],
|
if (typeof document !== 'undefined') {
|
||||||
});
|
document.dispatchEvent(new CustomEvent('encryptid:guardian-notification', {
|
||||||
|
detail: {
|
||||||
|
event,
|
||||||
|
requestId: request.id,
|
||||||
|
guardianIds: this.config.guardians.map(g => g.id),
|
||||||
|
},
|
||||||
|
}));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -449,20 +449,22 @@ export class GuardianSetupElement extends HTMLElement {
|
||||||
|
|
||||||
connectedCallback() {
|
connectedCallback() {
|
||||||
this.loadConfig();
|
this.loadConfig();
|
||||||
this.render();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private loadConfig() {
|
private async loadConfig() {
|
||||||
const manager = getRecoveryManager();
|
const manager = getRecoveryManager();
|
||||||
|
|
||||||
|
// Ensure server sync is complete before reading config
|
||||||
|
await manager.ensureLoaded();
|
||||||
this.config = manager.getConfig();
|
this.config = manager.getConfig();
|
||||||
|
|
||||||
// Initialize if not configured
|
// Initialize if not configured
|
||||||
if (!this.config) {
|
if (!this.config) {
|
||||||
manager.initializeRecovery(3).then(() => {
|
await manager.initializeRecovery(3);
|
||||||
this.config = manager.getConfig();
|
this.config = manager.getConfig();
|
||||||
this.render();
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
this.render();
|
||||||
}
|
}
|
||||||
|
|
||||||
private render() {
|
private render() {
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue