@affectively/dash 5.0.0 → 5.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,39 +1,585 @@
1
1
  import * as ucans from 'ucans';
2
+ const DB_NAME = 'dash-auth';
3
+ const DB_VERSION = 1;
4
+ const STORE_NAME = 'identity';
5
+ const IDENTITY_KEY = 'primary';
6
+ /**
7
+ * Check if IndexedDB is available
8
+ */
9
+ function isIndexedDBAvailable() {
10
+ try {
11
+ return typeof indexedDB !== 'undefined' && indexedDB !== null;
12
+ }
13
+ catch {
14
+ return false;
15
+ }
16
+ }
17
+ /**
18
+ * Opens the IndexedDB database for auth storage
19
+ * Returns null if IndexedDB is not available
20
+ */
21
+ async function openAuthDB() {
22
+ if (!isIndexedDBAvailable()) {
23
+ return null;
24
+ }
25
+ return new Promise((resolve, reject) => {
26
+ const request = indexedDB.open(DB_NAME, DB_VERSION);
27
+ request.onerror = () => reject(request.error);
28
+ request.onsuccess = () => resolve(request.result);
29
+ request.onupgradeneeded = (event) => {
30
+ const db = event.target.result;
31
+ if (!db.objectStoreNames.contains(STORE_NAME)) {
32
+ db.createObjectStore(STORE_NAME);
33
+ }
34
+ };
35
+ });
36
+ }
37
+ /**
38
+ * Derives an encryption key from a password using PBKDF2
39
+ */
40
+ async function deriveKeyFromPassword(password, salt) {
41
+ const encoder = new TextEncoder();
42
+ const keyMaterial = await crypto.subtle.importKey('raw', encoder.encode(password), 'PBKDF2', false, ['deriveKey']);
43
+ // Create a copy of salt to ensure it's a proper ArrayBuffer
44
+ const saltBuffer = new Uint8Array(salt).buffer;
45
+ return crypto.subtle.deriveKey({
46
+ name: 'PBKDF2',
47
+ salt: saltBuffer,
48
+ iterations: 100000,
49
+ hash: 'SHA-256'
50
+ }, keyMaterial, { name: 'AES-GCM', length: 256 }, false, ['encrypt', 'decrypt']);
51
+ }
52
+ /**
53
+ * Encrypts string data with AES-GCM
54
+ */
55
+ async function encryptString(data, key) {
56
+ const encoder = new TextEncoder();
57
+ const dataBytes = encoder.encode(data);
58
+ const iv = crypto.getRandomValues(new Uint8Array(12));
59
+ // Create copies to ensure proper ArrayBuffer (not SharedArrayBuffer)
60
+ const ivBuffer = new Uint8Array(iv).buffer;
61
+ const dataBuffer = new Uint8Array(dataBytes).buffer;
62
+ const encrypted = await crypto.subtle.encrypt({ name: 'AES-GCM', iv: ivBuffer }, key, dataBuffer);
63
+ return { encrypted: new Uint8Array(encrypted), iv };
64
+ }
65
+ /**
66
+ * Decrypts data with AES-GCM and returns as string
67
+ */
68
+ async function decryptString(encrypted, iv, key) {
69
+ // Create copies to ensure proper ArrayBuffer (not SharedArrayBuffer)
70
+ const ivBuffer = new Uint8Array(iv).buffer;
71
+ const encryptedBuffer = new Uint8Array(encrypted).buffer;
72
+ const decrypted = await crypto.subtle.decrypt({ name: 'AES-GCM', iv: ivBuffer }, key, encryptedBuffer);
73
+ const decoder = new TextDecoder();
74
+ return decoder.decode(decrypted);
75
+ }
76
+ /**
77
+ * Converts Uint8Array to base64 string
78
+ */
79
+ function uint8ArrayToBase64(arr) {
80
+ return btoa(String.fromCharCode(...arr));
81
+ }
82
+ /**
83
+ * Converts base64 string to Uint8Array
84
+ */
85
+ function base64ToUint8Array(base64) {
86
+ const binary = atob(base64);
87
+ const bytes = new Uint8Array(binary.length);
88
+ for (let i = 0; i < binary.length; i++) {
89
+ bytes[i] = binary.charCodeAt(i);
90
+ }
91
+ return bytes;
92
+ }
2
93
  export class AuthManager {
3
94
  keypair = null;
4
95
  did = null;
96
+ oauthIdentities = [];
97
+ relayUrl;
98
+ initialized = false;
99
+ constructor(relayUrl = 'https://relay.buley.dev') {
100
+ this.relayUrl = relayUrl;
101
+ }
102
+ /**
103
+ * Initialize the auth manager, loading existing keypair or creating new one
104
+ */
5
105
  async init() {
6
- if (!this.keypair) {
7
- this.keypair = await ucans.EdKeypair.create();
106
+ if (this.initialized && this.did) {
107
+ return this.did;
108
+ }
109
+ // Try to load existing keypair from IndexedDB
110
+ const loaded = await this.loadKeypair();
111
+ if (!loaded) {
112
+ // Create new keypair if none exists (exportable so we can save it)
113
+ this.keypair = await ucans.EdKeypair.create({ exportable: true });
8
114
  this.did = this.keypair.did();
9
115
  }
116
+ this.initialized = true;
10
117
  return this.did;
11
118
  }
119
+ /**
120
+ * Get the DID (Decentralized Identifier)
121
+ */
12
122
  getDID() {
13
123
  if (!this.did)
14
124
  throw new Error("AuthManager not initialized");
15
125
  return this.did;
16
126
  }
127
+ /**
128
+ * Get linked OAuth identities
129
+ */
130
+ getLinkedIdentities() {
131
+ return [...this.oauthIdentities];
132
+ }
133
+ /**
134
+ * Check if initialized
135
+ */
136
+ isInitialized() {
137
+ return this.initialized && this.keypair !== null;
138
+ }
139
+ /**
140
+ * Save the keypair to IndexedDB (encrypted with password)
141
+ * This enables persistent identity across sessions
142
+ */
143
+ async saveKeypair(password) {
144
+ if (!this.keypair || !this.did) {
145
+ throw new Error("No keypair to save - call init() first");
146
+ }
147
+ // Generate salt for PBKDF2
148
+ const salt = crypto.getRandomValues(new Uint8Array(16));
149
+ // Derive encryption key from password
150
+ const encryptionKey = await deriveKeyFromPassword(password, salt);
151
+ // Export the keypair's private key (returns base64-encoded string)
152
+ const privateKeyString = await this.keypair.export();
153
+ // Encrypt the private key string
154
+ const { encrypted, iv } = await encryptString(privateKeyString, encryptionKey);
155
+ // Combine IV and encrypted data
156
+ const combined = new Uint8Array(iv.length + encrypted.length);
157
+ combined.set(iv);
158
+ combined.set(encrypted, iv.length);
159
+ // Prepare stored identity
160
+ const storedIdentity = {
161
+ did: this.did,
162
+ publicKey: this.did, // DID contains the public key
163
+ encryptedPrivateKey: uint8ArrayToBase64(combined),
164
+ salt: uint8ArrayToBase64(salt),
165
+ oauthIdentities: this.oauthIdentities,
166
+ createdAt: new Date().toISOString(),
167
+ updatedAt: new Date().toISOString()
168
+ };
169
+ // Store in IndexedDB (skip if not available)
170
+ const db = await openAuthDB();
171
+ if (!db) {
172
+ console.warn('[AuthManager] IndexedDB not available, keypair not persisted');
173
+ return;
174
+ }
175
+ return new Promise((resolve, reject) => {
176
+ const tx = db.transaction(STORE_NAME, 'readwrite');
177
+ const store = tx.objectStore(STORE_NAME);
178
+ const request = store.put(storedIdentity, IDENTITY_KEY);
179
+ request.onerror = () => reject(request.error);
180
+ request.onsuccess = () => resolve();
181
+ tx.oncomplete = () => db.close();
182
+ });
183
+ }
184
+ /**
185
+ * Load keypair from IndexedDB (requires password to decrypt)
186
+ * Returns true if successfully loaded, false if no stored identity
187
+ */
188
+ async loadKeypair(password) {
189
+ try {
190
+ const db = await openAuthDB();
191
+ if (!db) {
192
+ // IndexedDB not available
193
+ return false;
194
+ }
195
+ const storedIdentity = await new Promise((resolve, reject) => {
196
+ const tx = db.transaction(STORE_NAME, 'readonly');
197
+ const store = tx.objectStore(STORE_NAME);
198
+ const request = store.get(IDENTITY_KEY);
199
+ request.onerror = () => reject(request.error);
200
+ request.onsuccess = () => resolve(request.result);
201
+ tx.oncomplete = () => db.close();
202
+ });
203
+ if (!storedIdentity) {
204
+ return false;
205
+ }
206
+ // If no password provided, we can only restore the DID (not the full keypair)
207
+ if (!password) {
208
+ this.did = storedIdentity.did;
209
+ this.oauthIdentities = storedIdentity.oauthIdentities || [];
210
+ // Keypair remains null - user needs to provide password for signing operations
211
+ return true;
212
+ }
213
+ // Decrypt the private key
214
+ const salt = base64ToUint8Array(storedIdentity.salt);
215
+ const encryptionKey = await deriveKeyFromPassword(password, salt);
216
+ const combined = base64ToUint8Array(storedIdentity.encryptedPrivateKey);
217
+ const iv = combined.slice(0, 12);
218
+ const encrypted = combined.slice(12);
219
+ const privateKeyString = await decryptString(encrypted, iv, encryptionKey);
220
+ // Reconstruct the keypair from the private key (base64 string)
221
+ this.keypair = ucans.EdKeypair.fromSecretKey(privateKeyString, { exportable: true });
222
+ this.did = this.keypair.did();
223
+ this.oauthIdentities = storedIdentity.oauthIdentities || [];
224
+ return true;
225
+ }
226
+ catch (error) {
227
+ console.error('[AuthManager] Failed to load keypair:', error);
228
+ return false;
229
+ }
230
+ }
231
+ /**
232
+ * Bind an OAuth identity to this UCAN identity
233
+ * Verifies the OAuth token with the Relay and stores the binding
234
+ */
235
+ async bindOAuthIdentity(provider, oauthToken, password) {
236
+ if (!this.keypair || !this.did) {
237
+ throw new Error("AuthManager not initialized");
238
+ }
239
+ // Verify OAuth token with Relay and get identity info
240
+ const response = await fetch(`${this.relayUrl}/auth/oauth/${provider}`, {
241
+ method: 'POST',
242
+ headers: {
243
+ 'Content-Type': 'application/json'
244
+ },
245
+ body: JSON.stringify({
246
+ oauthToken,
247
+ did: this.did
248
+ })
249
+ });
250
+ if (!response.ok) {
251
+ const error = await response.text();
252
+ throw new Error(`OAuth verification failed: ${error}`);
253
+ }
254
+ const identity = await response.json();
255
+ // Add to linked identities (prevent duplicates)
256
+ const existingIndex = this.oauthIdentities.findIndex(i => i.provider === provider && i.providerId === identity.providerId);
257
+ if (existingIndex >= 0) {
258
+ this.oauthIdentities[existingIndex] = identity;
259
+ }
260
+ else {
261
+ this.oauthIdentities.push(identity);
262
+ }
263
+ // Re-save with updated identities
264
+ await this.saveKeypair(password);
265
+ return identity;
266
+ }
267
+ /**
268
+ * Recover keypair from cloud escrow using password
269
+ * Fetches encrypted escrow from Relay and decrypts locally
270
+ */
271
+ async recoverFromEscrow(provider, oauthToken, password) {
272
+ // First, verify OAuth to prove identity
273
+ const verifyResponse = await fetch(`${this.relayUrl}/auth/oauth/${provider}/verify`, {
274
+ method: 'POST',
275
+ headers: {
276
+ 'Content-Type': 'application/json'
277
+ },
278
+ body: JSON.stringify({ oauthToken })
279
+ });
280
+ if (!verifyResponse.ok) {
281
+ throw new Error('OAuth verification failed');
282
+ }
283
+ const { userId } = await verifyResponse.json();
284
+ // Fetch encrypted escrow
285
+ const escrowResponse = await fetch(`${this.relayUrl}/auth/escrow/${userId}`, {
286
+ method: 'GET',
287
+ headers: {
288
+ 'Authorization': `Bearer ${oauthToken}`
289
+ }
290
+ });
291
+ if (!escrowResponse.ok) {
292
+ if (escrowResponse.status === 404) {
293
+ return false; // No escrow found
294
+ }
295
+ throw new Error('Failed to fetch escrow');
296
+ }
297
+ const escrow = await escrowResponse.json();
298
+ // Decrypt using password
299
+ const salt = base64ToUint8Array(escrow.salt);
300
+ const encryptionKey = await deriveKeyFromPassword(password, salt);
301
+ const combined = base64ToUint8Array(escrow.encryptedKeypair);
302
+ const iv = combined.slice(0, 12);
303
+ const encrypted = combined.slice(12);
304
+ try {
305
+ const privateKeyString = await decryptString(encrypted, iv, encryptionKey);
306
+ // Reconstruct keypair from base64 string
307
+ this.keypair = ucans.EdKeypair.fromSecretKey(privateKeyString, { exportable: true });
308
+ this.did = this.keypair.did();
309
+ this.oauthIdentities = escrow.linkedIdentities || [];
310
+ // Save locally
311
+ await this.saveKeypair(password);
312
+ this.initialized = true;
313
+ return true;
314
+ }
315
+ catch (error) {
316
+ throw new Error('Invalid password - decryption failed');
317
+ }
318
+ }
319
+ /**
320
+ * Upload keypair escrow to cloud for recovery
321
+ * The escrow is encrypted with the user's password
322
+ */
323
+ async createEscrow(password) {
324
+ if (!this.keypair || !this.did) {
325
+ throw new Error("AuthManager not initialized");
326
+ }
327
+ // Generate salt
328
+ const salt = crypto.getRandomValues(new Uint8Array(16));
329
+ const encryptionKey = await deriveKeyFromPassword(password, salt);
330
+ // Export and encrypt private key (export returns base64 string)
331
+ const privateKeyString = await this.keypair.export();
332
+ const { encrypted, iv } = await encryptString(privateKeyString, encryptionKey);
333
+ const combined = new Uint8Array(iv.length + encrypted.length);
334
+ combined.set(iv);
335
+ combined.set(encrypted, iv.length);
336
+ // Upload to relay
337
+ const response = await fetch(`${this.relayUrl}/auth/escrow`, {
338
+ method: 'POST',
339
+ headers: {
340
+ 'Content-Type': 'application/json'
341
+ },
342
+ body: JSON.stringify({
343
+ did: this.did,
344
+ encryptedKeypair: uint8ArrayToBase64(combined),
345
+ salt: uint8ArrayToBase64(salt),
346
+ linkedIdentities: this.oauthIdentities
347
+ })
348
+ });
349
+ if (!response.ok) {
350
+ throw new Error('Failed to create escrow');
351
+ }
352
+ }
353
+ /**
354
+ * Issue a UCAN token for room access
355
+ */
17
356
  async issueRoomToken(roomAudience, roomName) {
18
- if (!this.keypair)
357
+ // Auto-initialize if not already done
358
+ if (!this.keypair) {
19
359
  await this.init();
20
- // In a real app, this "Root" UCAN would come from a server or a stored root key.
21
- // For this P2P/Star architecture, we are self-issuing a delegated capability
22
- // or acting as a self-signed root for the session if the Relay permits "open" access valid signatures.
23
- // Assuming the Relay acts as a gatekeeper that validates the signature is valid,
24
- // and perhaps checks a "known list" or just logs the DID.
25
- // Capability: "room/write" on "room://<roomName>"
360
+ }
361
+ if (!this.keypair) {
362
+ throw new Error("Keypair not loaded - provide password to unlock");
363
+ }
26
364
  const capability = {
27
365
  with: { scheme: "room", hierPart: `//${roomName}` },
28
366
  can: { namespace: "room", segments: ["write"] }
29
367
  };
30
368
  const ucan = await ucans.build({
31
- audience: roomAudience, // The Relay's DID
369
+ audience: roomAudience,
32
370
  issuer: this.keypair,
33
371
  capabilities: [capability],
34
- lifetimeInSeconds: 3600 // 1 hour token
372
+ lifetimeInSeconds: 3600
373
+ });
374
+ return ucans.encode(ucan);
375
+ }
376
+ /**
377
+ * Issue a UCAN token with custom capabilities
378
+ */
379
+ async issueToken(audience, capabilities, lifetimeInSeconds = 3600) {
380
+ if (!this.keypair) {
381
+ throw new Error("Keypair not loaded - provide password to unlock");
382
+ }
383
+ const ucan = await ucans.build({
384
+ audience,
385
+ issuer: this.keypair,
386
+ capabilities,
387
+ lifetimeInSeconds
35
388
  });
36
389
  return ucans.encode(ucan);
37
390
  }
391
+ /**
392
+ * Clear local identity (logout)
393
+ */
394
+ async clearIdentity() {
395
+ this.keypair = null;
396
+ this.did = null;
397
+ this.oauthIdentities = [];
398
+ this.initialized = false;
399
+ try {
400
+ const db = await openAuthDB();
401
+ if (!db) {
402
+ return; // IndexedDB not available
403
+ }
404
+ await new Promise((resolve, reject) => {
405
+ const tx = db.transaction(STORE_NAME, 'readwrite');
406
+ const store = tx.objectStore(STORE_NAME);
407
+ const request = store.delete(IDENTITY_KEY);
408
+ request.onerror = () => reject(request.error);
409
+ request.onsuccess = () => resolve();
410
+ tx.oncomplete = () => db.close();
411
+ });
412
+ }
413
+ catch (error) {
414
+ console.error('[AuthManager] Failed to clear identity:', error);
415
+ }
416
+ }
417
+ // ============================================
418
+ // INTROSPECTION METHODS FOR DASH-STUDIO
419
+ // ============================================
420
+ /**
421
+ * Parse a UCAN token and extract its structure without verification
422
+ * This is for inspection/debugging purposes
423
+ */
424
+ parseUCAN(token) {
425
+ try {
426
+ const parts = token.split('.');
427
+ if (parts.length !== 3) {
428
+ return {
429
+ valid: false,
430
+ error: 'Invalid token format: expected 3 parts (header.payload.signature)'
431
+ };
432
+ }
433
+ // Decode header
434
+ let header;
435
+ try {
436
+ header = JSON.parse(atob(parts[0].replace(/-/g, '+').replace(/_/g, '/')));
437
+ }
438
+ catch {
439
+ return { valid: false, error: 'Failed to decode header' };
440
+ }
441
+ // Decode payload
442
+ let payload;
443
+ try {
444
+ payload = JSON.parse(atob(parts[1].replace(/-/g, '+').replace(/_/g, '/')));
445
+ }
446
+ catch {
447
+ return { valid: false, error: 'Failed to decode payload' };
448
+ }
449
+ const now = Math.floor(Date.now() / 1000);
450
+ // Check expiration status
451
+ const isExpired = payload.exp ? payload.exp < now : false;
452
+ const isNotYetValid = payload.nbf ? payload.nbf > now : false;
453
+ // Calculate time until expiration
454
+ const expiresIn = payload.exp ? payload.exp - now : null;
455
+ // Parse capabilities
456
+ const capabilities = (payload.att || []).map((att) => ({
457
+ can: att.can || '',
458
+ with: att.with || '',
459
+ delegationDepth: att.delegationDepth,
460
+ constraints: att.constraints
461
+ }));
462
+ // Extract facts
463
+ const facts = payload.fct || {};
464
+ return {
465
+ valid: true,
466
+ header: {
467
+ alg: header.alg,
468
+ typ: header.typ,
469
+ ucv: header.ucv
470
+ },
471
+ payload: {
472
+ iss: payload.iss,
473
+ aud: payload.aud,
474
+ exp: payload.exp,
475
+ nbf: payload.nbf,
476
+ nnc: payload.nnc,
477
+ att: capabilities,
478
+ prf: payload.prf || [],
479
+ fct: facts
480
+ },
481
+ signature: parts[2],
482
+ meta: {
483
+ isExpired,
484
+ isNotYetValid,
485
+ expiresIn,
486
+ proofCount: (payload.prf || []).length,
487
+ capabilityCount: capabilities.length,
488
+ hasKarma: 'karma' in facts,
489
+ karma: typeof facts.karma === 'number' ? facts.karma : undefined
490
+ }
491
+ };
492
+ }
493
+ catch (error) {
494
+ return {
495
+ valid: false,
496
+ error: error instanceof Error ? error.message : 'Failed to parse token'
497
+ };
498
+ }
499
+ }
500
+ /**
501
+ * Check if keypair is loaded and ready for signing
502
+ */
503
+ isKeypairLoaded() {
504
+ return this.keypair !== null;
505
+ }
506
+ /**
507
+ * Get current DID or null if not initialized
508
+ */
509
+ getCurrentDID() {
510
+ return this.did;
511
+ }
512
+ /**
513
+ * Get full auth status for introspection
514
+ */
515
+ getAuthStatus() {
516
+ return {
517
+ initialized: this.initialized,
518
+ keypairLoaded: this.keypair !== null,
519
+ did: this.did,
520
+ linkedIdentityCount: this.oauthIdentities.length,
521
+ linkedProviders: this.oauthIdentities.map(i => i.provider),
522
+ relayUrl: this.relayUrl
523
+ };
524
+ }
525
+ /**
526
+ * Get linked devices (from OAuth identities)
527
+ */
528
+ getLinkedDevices() {
529
+ return this.oauthIdentities.map(identity => ({
530
+ provider: identity.provider,
531
+ providerId: identity.providerId,
532
+ email: identity.email,
533
+ displayName: identity.displayName,
534
+ photoURL: identity.photoURL,
535
+ linkedAt: identity.linkedAt
536
+ }));
537
+ }
538
+ /**
539
+ * Generate a device linking token for QR code display
540
+ * This token allows another device to link to this identity
541
+ */
542
+ async generateLinkToken(expirationMinutes = 5) {
543
+ if (!this.keypair || !this.did) {
544
+ return null;
545
+ }
546
+ const expiresAt = new Date(Date.now() + expirationMinutes * 60 * 1000);
547
+ // Create a capability that allows device linking
548
+ const linkCapability = {
549
+ with: { scheme: 'device', hierPart: `//link/${this.did}` },
550
+ can: { namespace: 'device', segments: ['link'] }
551
+ };
552
+ const ucan = await ucans.build({
553
+ audience: 'did:web:relay.buley.dev',
554
+ issuer: this.keypair,
555
+ capabilities: [linkCapability],
556
+ lifetimeInSeconds: expirationMinutes * 60
557
+ });
558
+ const token = ucans.encode(ucan);
559
+ return {
560
+ token,
561
+ did: this.did,
562
+ expiresAt: expiresAt.toISOString(),
563
+ qrData: JSON.stringify({
564
+ type: 'dash-device-link',
565
+ token,
566
+ did: this.did,
567
+ relay: this.relayUrl
568
+ })
569
+ };
570
+ }
571
+ /**
572
+ * Revoke a linked device (by provider and providerId)
573
+ */
574
+ async revokeDevice(provider, providerId, password) {
575
+ const index = this.oauthIdentities.findIndex(i => i.provider === provider && i.providerId === providerId);
576
+ if (index === -1) {
577
+ return false;
578
+ }
579
+ this.oauthIdentities.splice(index, 1);
580
+ // Re-save with updated identities
581
+ await this.saveKeypair(password);
582
+ return true;
583
+ }
38
584
  }
39
585
  export const auth = new AuthManager();