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:
Jeff Emmett 2026-03-11 23:26:52 -07:00
parent 21b31c43c7
commit e207b18adf
3 changed files with 314 additions and 79 deletions

View File

@ -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<string> {
// 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
// ============================================================================

View File

@ -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<RecoveryConfig> {
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<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> {
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<void> {
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<boolean> {
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.saveConfig();
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.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<void> {
private async saveLocalSettings(): Promise<void> {
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<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(
event: string,
request: RecoveryRequest
): 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 });
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<void> {
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],
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),
},
}));
}
}
}

View File

@ -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(() => {
await manager.initializeRecovery(3);
this.config = manager.getConfig();
this.render();
});
}
this.render();
}
private render() {