@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.
- package/dist/src/auth/manager.d.ts +171 -1
- package/dist/src/auth/manager.js +557 -11
- package/dist/src/engine/sqlite.d.ts +135 -0
- package/dist/src/engine/sqlite.js +298 -0
- package/dist/src/sync/aeon/config.d.ts +21 -0
- package/dist/src/sync/aeon/config.js +14 -0
- package/dist/src/sync/aeon/delta-adapter.d.ts +62 -0
- package/dist/src/sync/aeon/delta-adapter.js +98 -0
- package/dist/src/sync/aeon/index.d.ts +18 -0
- package/dist/src/sync/aeon/index.js +19 -0
- package/dist/src/sync/aeon/offline-adapter.d.ts +110 -0
- package/dist/src/sync/aeon/offline-adapter.js +223 -0
- package/dist/src/sync/aeon/presence-adapter.d.ts +114 -0
- package/dist/src/sync/aeon/presence-adapter.js +157 -0
- package/dist/src/sync/aeon/schema-adapter.d.ts +95 -0
- package/dist/src/sync/aeon/schema-adapter.js +163 -0
- package/dist/src/sync/hybrid-provider.d.ts +146 -2
- package/dist/src/sync/hybrid-provider.js +291 -8
- package/dist/tsconfig.tsbuildinfo +1 -1
- package/package.json +8 -2
package/dist/src/auth/manager.js
CHANGED
|
@@ -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 (
|
|
7
|
-
this.
|
|
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
|
|
357
|
+
// Auto-initialize if not already done
|
|
358
|
+
if (!this.keypair) {
|
|
19
359
|
await this.init();
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
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,
|
|
369
|
+
audience: roomAudience,
|
|
32
370
|
issuer: this.keypair,
|
|
33
371
|
capabilities: [capability],
|
|
34
|
-
lifetimeInSeconds: 3600
|
|
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();
|