@sovereignbase/explorer 0.0.0-alpha.4 → 0.0.0-alpha.6

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/class.js CHANGED
@@ -18,7 +18,7 @@ import { deleteObject, loadObject, storeObject } from "@sovereignbase/storage";
18
18
  import { afterIdleFor, isRecord } from "@sovereignbase/utils";
19
19
  import { Bytes } from "@sovereignbase/bytecodec";
20
20
  import { readableUserAgent } from "@sovereignbase/dom-utils";
21
- import { deriveDeviceEntropy } from "@sovereignbase/hardware-bound";
21
+ import { accessDeviceBinding, createDeviceBinding, invalidateDeviceBinding } from "@sovereignbase/hardware-bound";
22
22
  import { Cryptographic } from "@sovereignbase/cryptosuite";
23
23
  import { Sequenced } from "@sovereignbase/sequenced";
24
24
  // src/.errors/class.ts
@@ -56,10 +56,11 @@ function unknownError(cause, operation) {
56
56
  }
57
57
  // src/CredentialEntries/class.ts
58
58
  /**
59
- * Maintains the database's credential entries.
59
+ * Maintains the database's registered credentials.
60
60
  *
61
- * Entries are identified by credential ID and synchronized through
62
- * a sequenced array. Changes are persisted as encrypted snapshots.
61
+ * Entries are identified by their object and credential identifiers.
62
+ * Changes are synchronized through a sequenced array and persisted
63
+ * as encrypted snapshots.
63
64
  */
64
65
  var CredentialEntries = class {
65
66
  id;
@@ -73,7 +74,7 @@ var CredentialEntries = class {
73
74
  *
74
75
  * @param id The identifier of the persisted entries object.
75
76
  * @param cipherKey The key used to encrypt the entries object.
76
- * @param sessionCredentialID The credential ID of the current session.
77
+ * @param sessionCredentialID The WebAuthn credential ID of the current session.
77
78
  * @param actorID The actor identifier used by the sequenced array.
78
79
  * @param trustedSnapshot An optional snapshot used to initialize the state.
79
80
  */
@@ -99,13 +100,17 @@ var CredentialEntries = class {
99
100
  this.state.addEventListener("change", ({ detail }) => {
100
101
  this.dispatchEvent("change", detail);
101
102
  });
102
- const seen = /* @__PURE__ */ new Set();
103
+ const seenObjects = /* @__PURE__ */ new Set();
104
+ const seenCredentials = /* @__PURE__ */ new Set();
103
105
  const duplicates = [];
104
106
  for (let index = 0; index < this.state.length; index++) {
105
107
  const entry = this.state.at(index);
106
108
  if (!entry) continue;
107
- if (seen.has(entry.id)) duplicates.push(index);
108
- else seen.add(entry.id);
109
+ if (seenObjects.has(entry.objectID) || seenCredentials.has(entry.credentialID)) duplicates.push(index);
110
+ else {
111
+ seenObjects.add(entry.objectID);
112
+ seenCredentials.add(entry.credentialID);
113
+ }
109
114
  }
110
115
  for (let index = duplicates.length - 1; index >= 0; index--) this.state.delete(duplicates[index]);
111
116
  if (duplicates.length > 0 || this.deltaBuffer.length > 0) this.state.snapshot();
@@ -126,17 +131,17 @@ var CredentialEntries = class {
126
131
  record(credentialEvent) {
127
132
  try {
128
133
  if (!credentialEvent || typeof credentialEvent !== "object") throw new ExplorerError("INVALID_CREDENTIAL_EVENT", "A credential event must be an object.");
129
- const { id, timestamp, userAgent, name, entropy, storage } = credentialEvent;
130
- if (typeof id !== "string" || typeof timestamp !== "number" || !Number.isFinite(timestamp) || typeof userAgent !== "string") throw new ExplorerError("INVALID_CREDENTIAL_EVENT", "A credential event requires an ID, a finite timestamp, and a user agent.");
134
+ const { objectID, credentialID, timestamp, userAgent, name, entropy, storage } = credentialEvent;
135
+ if (typeof objectID !== "string" || typeof timestamp !== "number" || !Number.isFinite(timestamp) || typeof userAgent !== "string") throw new ExplorerError("INVALID_CREDENTIAL_EVENT", "A credential event requires an object ID, a finite timestamp, and a user agent.");
131
136
  let existing;
132
137
  for (let index = this.state.length - 1; index >= 0; index--) {
133
138
  const entry = this.state.at(index);
134
- if (entry?.id === id) {
139
+ if (entry?.objectID === objectID || credentialID !== void 0 && entry?.credentialID === credentialID) {
135
140
  existing = entry;
136
141
  break;
137
142
  }
138
143
  }
139
- if (!existing && (typeof name !== "string" || !(entropy instanceof Uint8Array) || typeof storage !== "string")) throw new ExplorerError("MISSING_CREDENTIAL_SETUP_PROPERTIES", "A new credential requires a name, entropy, and binding storage.");
144
+ if (!existing && (typeof credentialID !== "string" || typeof name !== "string" || !(entropy instanceof Uint8Array) || typeof storage !== "string")) throw new ExplorerError("MISSING_CREDENTIAL_SETUP_PROPERTIES", "A new credential requires a credential ID, name, entropy, and binding storage.");
140
145
  const userAgents = existing?.userAgents.map((agent) => ({ ...agent })) ?? [];
141
146
  const agent = userAgents.find((agent) => agent.name === userAgent);
142
147
  if (agent) agent.lastSeen = Math.max(agent.lastSeen, timestamp);
@@ -145,14 +150,18 @@ var CredentialEntries = class {
145
150
  lastSeen: timestamp
146
151
  });
147
152
  const entry = {
148
- id,
153
+ objectID,
154
+ credentialID: credentialID ?? existing.credentialID,
149
155
  name: name ?? existing.name,
150
156
  entropy: entropy ?? existing.entropy,
151
157
  storage: storage ?? existing.storage,
152
158
  lastUsed: Math.max(existing?.lastUsed ?? timestamp, timestamp),
153
159
  userAgents
154
160
  };
155
- for (let index = this.state.length - 1; index >= 0; index--) if (this.state.at(index)?.id === id) this.state.delete(index);
161
+ for (let index = this.state.length - 1; index >= 0; index--) {
162
+ const current = this.state.at(index);
163
+ if (current?.objectID === entry.objectID || current?.credentialID === entry.credentialID) this.state.delete(index);
164
+ }
156
165
  this.state.unshift(entry);
157
166
  this.state.snapshot();
158
167
  } catch (cause) {
@@ -160,24 +169,33 @@ var CredentialEntries = class {
160
169
  }
161
170
  }
162
171
  /**
163
- * Revokes a credential by deleting its persisted data and list entries.
172
+ * Revokes a credential and removes its registered entries.
164
173
  *
165
- * The list remains unchanged if deletion of the persisted credential fails.
174
+ * Deletes the credential's persisted objects before removing its entries.
175
+ * The entries remain unchanged if deletion fails. Credential invalidation
176
+ * is requested on a best-effort basis after successful removal.
166
177
  *
167
- * @param credentialID The identifier of the credential to revoke.
178
+ * @param credentialID The WebAuthn credential identifier to revoke.
168
179
  */
169
180
  async revoke(credentialID) {
170
181
  try {
171
- let exists = false;
182
+ const objectIDs = /* @__PURE__ */ new Set();
172
183
  for (let index = this.state.length - 1; index >= 0; index--) {
173
- if (this.state.at(index)?.id !== credentialID) continue;
174
- exists = true;
175
- break;
184
+ const entry = this.state.at(index);
185
+ if (entry?.credentialID === credentialID) objectIDs.add(entry.objectID);
176
186
  }
177
- if (!exists) return;
178
- await Explorer.deleteObjectByID(credentialID);
179
- for (let index = this.state.length - 1; index >= 0; index--) if (this.state.at(index)?.id === credentialID) this.state.delete(index);
180
- this.state.snapshot();
187
+ if (objectIDs.size === 0) return;
188
+ for (const objectID of objectIDs) await Explorer.deleteObjectByID(objectID);
189
+ let removed = false;
190
+ for (let index = this.state.length - 1; index >= 0; index--) if (this.state.at(index)?.credentialID === credentialID) {
191
+ this.state.delete(index);
192
+ removed = true;
193
+ }
194
+ if (removed) this.state.snapshot();
195
+ try {
196
+ await invalidateDeviceBinding(credentialID);
197
+ } catch {}
198
+ if (credentialID === this.sessionCredentialID) window.location.reload();
181
199
  } catch (cause) {
182
200
  throw unknownError(cause, "CredentialEntries.revoke()");
183
201
  }
@@ -185,11 +203,11 @@ var CredentialEntries = class {
185
203
  /**
186
204
  * Returns the credential entries ordered by most recent use.
187
205
  *
188
- * Entries with equal timestamps are ordered by identifier.
206
+ * Entries with equal timestamps are ordered by credential identifier.
189
207
  */
190
208
  list() {
191
209
  try {
192
- return this.state.slice().sort((a, b) => b.lastUsed - a.lastUsed || a.id.localeCompare(b.id));
210
+ return this.state.slice().sort((a, b) => b.lastUsed - a.lastUsed || a.credentialID.localeCompare(b.credentialID));
193
211
  } catch (cause) {
194
212
  throw unknownError(cause, "CredentialEntries.list()");
195
213
  }
@@ -438,20 +456,27 @@ var RecentEntries = class {
438
456
  };
439
457
  // src/class.ts
440
458
  /**
441
- * Provides authentication, encrypted object storage, and access to
442
- * the database's synchronized entries.
459
+ * Provides access to an authenticated Sovereignbase database.
443
460
  *
444
- * Maintains the authenticated database context and exposes static methods
445
- * for managing stored objects, observing entries, and applying remote deltas.
461
+ * Manages database authentication, encrypted object storage, credential
462
+ * registration, database linking, and synchronized entries.
446
463
  */
447
464
  var Explorer = class {
448
465
  /** The identifier of the authenticated database. */
449
466
  static databaseID;
450
- /** Indicates whether the database has been authenticated. */
467
+ /** Indicates whether a database has been authenticated. */
451
468
  static isAuthenticated;
469
+ /** The base URL used to access database objects. */
452
470
  static storageURL;
471
+ /** The event target used for Explorer events. */
453
472
  static eventTarget;
473
+ /** The data PIN associated with the current session. */
474
+ static dataPIN;
475
+ /** The virtual entropy associated with the authenticated database. */
476
+ static virtualEntropy;
477
+ /** The secret used to access the authenticated database. */
454
478
  static databaseSecret;
479
+ /** The credential information associated with the current authentication. */
455
480
  static sessionCredential = {};
456
481
  /**
457
482
  * Caches cipher keys by object identifier.
@@ -461,46 +486,50 @@ var Explorer = class {
461
486
  */
462
487
  static cipherKeyCache = /* @__PURE__ */ new Map();
463
488
  /**
464
- * Authenticates a database using a data PIN and device-bound entropy.
489
+ * Authenticates a database using a data PIN and an existing device binding.
490
+ *
491
+ * Establishes the authenticated database context and records the credential
492
+ * used for authentication.
465
493
  *
466
- * Derives the credential and database identifiers from cryptographic
467
- * secrets. When setup parameters are provided, creates and stores a
468
- * new credential. Otherwise, loads the existing credential.
494
+ * When setup parameters are provided, registers a credential using the
495
+ * supplied virtual entropy or generates new virtual entropy.
469
496
  *
470
- * Records the authenticated credential in the database's credential list.
497
+ * @param dataPIN The PIN associated with the database.
498
+ * @param storageURL The base URL used to access database objects.
499
+ * @param setupParams Optional parameters for registering a credential.
471
500
  *
472
- * @param dataPIN The PIN used to derive the credential and database secrets.
473
- * @param storageURL The base URL used to access persisted database objects.
474
- * @param setupParams Optional parameters for creating a new credential.
501
+ * @param credentialId An optional credential identifier used to restrict credential selection.
475
502
  * @returns The authentication result.
476
503
  */
477
- static async authenticate(dataPIN, storageURL, setupParams) {
504
+ static async authenticate(dataPIN, storageURL, setupParams, credentialId) {
478
505
  try {
506
+ this.dataPIN = dataPIN;
479
507
  this.storageURL = storageURL;
480
- const deviceEntropy = await deriveDeviceEntropy();
481
- if (!deviceEntropy) return "authenticator-error";
508
+ const deviceBinding = await accessDeviceBinding(credentialId);
509
+ if (!Array.isArray(deviceBinding)) return "authenticator-error";
510
+ const [credentialID, deviceEntropy] = deviceBinding;
482
511
  this.sessionCredential = {
483
512
  timestamp: Date.now(),
484
513
  userAgent: readableUserAgent()
485
514
  };
486
515
  const dataPINBytes = Bytes.utf8.decode(dataPIN);
487
516
  const credentialSecret = await Bytes.derive(deviceEntropy, dataPINBytes, 32);
488
- const credentialID = await Cryptographic.identifier.derive(credentialSecret, Bytes.utf8.decode("@sovereignbase/explorer:CredentialID"), 32);
489
- this.sessionCredential.id = credentialID;
517
+ const credentialObjectID = await Cryptographic.identifier.derive(credentialSecret, Bytes.utf8.decode("@sovereignbase/explorer:CredentialObjectID"), 32);
518
+ this.sessionCredential.objectID = credentialObjectID;
519
+ this.sessionCredential.credentialID = credentialID;
490
520
  const credentialCipherKey = await Cryptographic.cipherMessage.deriveKey(credentialSecret, Bytes.utf8.decode("@sovereignbase/explorer:CredentialCipherKey"));
491
- let virtualEntropy;
492
521
  if (isRecord(setupParams)) {
493
- virtualEntropy = Bytes.generate(64);
494
- await this.storeObjectByID(credentialID, virtualEntropy, credentialCipherKey);
522
+ this.virtualEntropy = setupParams.virtualEntropy instanceof Uint8Array ? setupParams.virtualEntropy : Bytes.generate(64);
523
+ await this.storeObjectByID(credentialObjectID, this.virtualEntropy, credentialCipherKey);
495
524
  this.sessionCredential.name = setupParams.credentialName;
496
525
  this.sessionCredential.entropy = deviceEntropy;
497
526
  this.sessionCredential.storage = setupParams.bindingStorage;
498
527
  } else {
499
- const result = await this.loadObjectByID(credentialID, credentialCipherKey);
528
+ const result = await this.loadObjectByID(credentialObjectID, credentialCipherKey);
500
529
  if (!(result instanceof Uint8Array) || result.byteLength !== 64) return "invalid-data-pin";
501
- virtualEntropy = result;
530
+ this.virtualEntropy = result;
502
531
  }
503
- this.databaseSecret = await Bytes.derive(virtualEntropy, dataPINBytes, 64);
532
+ this.databaseSecret = await Bytes.derive(this.virtualEntropy, dataPINBytes, 64);
504
533
  this.databaseID = await Cryptographic.identifier.derive(this.databaseSecret, Bytes.utf8.decode("@sovereignbase/explorer:DatabaseID"), 32);
505
534
  this.isAuthenticated = true;
506
535
  (await this.loadCredentialEntries()).record(this.sessionCredential);
@@ -511,10 +540,71 @@ var Explorer = class {
511
540
  throw unknownError(cause, "Explorer.authenticate()");
512
541
  }
513
542
  }
543
+ /** The number of consecutive failed database linking PIN attempts. */
544
+ static failCounter = 0;
514
545
  /**
515
- * Permanently revokes a credential and deletes its persisted data.
546
+ * Prepares an authenticated database for linking to another device.
516
547
  *
517
- * Removes the credential from the database's registered credentials.
548
+ * Verifies the current data PIN and prepares an encrypted transfer
549
+ * containing the information required to access the same database.
550
+ *
551
+ * Repeated PIN failures cause the current page to reload.
552
+ *
553
+ * @param dataPIN The PIN associated with the authenticated database.
554
+ * @param encapsulateKey The key used to establish encryption for the transfer.
555
+ * @returns The key offer and encrypted transfer message, or `false`
556
+ * if the supplied PIN is incorrect.
557
+ */
558
+ static async prepareDatabaseLink(dataPIN, encapsulateKey) {
559
+ try {
560
+ if (!this.isAuthenticated) throw new ExplorerError("NOT_AUTHENTICATED");
561
+ if (dataPIN !== this.dataPIN) {
562
+ if (this.failCounter >= 2) window.location.reload();
563
+ this.failCounter++;
564
+ return false;
565
+ }
566
+ this.failCounter = 0;
567
+ const { keyOffer, cipherKey } = await Cryptographic.keyAgreement.encapsulate(encapsulateKey);
568
+ return [keyOffer, await Cryptographic.cipherMessage.encrypt(cipherKey, Bytes.concat([this.virtualEntropy, Bytes.utf8.decode(dataPIN)]))];
569
+ } catch (cause) {
570
+ throw unknownError(cause, "Explorer.prepareDatabaseLink()");
571
+ }
572
+ }
573
+ /**
574
+ * Links the current device to an existing database.
575
+ *
576
+ * Accesses the transferred database information, creates a device binding,
577
+ * and registers a credential for the existing database.
578
+ *
579
+ * @param keyOffer The key offer associated with the database link.
580
+ * @param cipherMessage The encrypted database linking message.
581
+ * @param decapsulateKey The key used to establish decryption for the transfer.
582
+ * @param credentialName The display name assigned to the new credential.
583
+ * @param storageURL The base URL used to access database objects.
584
+ * @returns `true` if the database was linked and authenticated;
585
+ * otherwise, `false`.
586
+ */
587
+ static async linkToDatabase(keyOffer, cipherMessage, decapsulateKey, credentialName, storageURL) {
588
+ try {
589
+ const { cipherKey } = await Cryptographic.keyAgreement.decapsulate(keyOffer, decapsulateKey);
590
+ const raw = await Cryptographic.cipherMessage.decrypt(cipherKey, cipherMessage);
591
+ const setupResult = await createDeviceBinding(credentialName);
592
+ if (!Array.isArray(setupResult)) return false;
593
+ const [credentialId, bindingStorage] = setupResult;
594
+ return await this.authenticate(Bytes.utf8.encode(raw.slice(64)), storageURL, {
595
+ credentialName,
596
+ virtualEntropy: raw.slice(0, 64),
597
+ bindingStorage
598
+ }, credentialId) === "authenticated" ? true : false;
599
+ } catch (cause) {
600
+ throw unknownError(cause, "Explorer.linkToDatabase()");
601
+ }
602
+ }
603
+ /**
604
+ * Permanently revokes a registered database credential.
605
+ *
606
+ * Removes the credential's persisted data and registration from
607
+ * the authenticated database.
518
608
  *
519
609
  * @param credentialID The identifier of the credential to revoke.
520
610
  * @throws {ExplorerError} If the database is not authenticated.
@@ -528,15 +618,14 @@ var Explorer = class {
528
618
  }
529
619
  }
530
620
  /**
531
- * Returns the registered credentials and optionally observes their changes.
621
+ * Returns the registered database credentials.
532
622
  *
533
- * The returned tuple contains a cleanup function, the current credential
534
- * entries, and the identifier of the current session's credential.
535
- *
536
- * The cleanup function removes the registered change listener, if any.
623
+ * Optionally registers a listener for changes to the credential entries.
624
+ * The returned cleanup function removes the registered listener.
537
625
  *
538
626
  * @param onChange An optional callback invoked when the entries change.
539
- * @returns The cleanup function, credential entries, and session credential ID.
627
+ * @returns The cleanup function, credential entries, and identifier
628
+ * of the current session's credential.
540
629
  * @throws {ExplorerError} If the database is not authenticated.
541
630
  */
542
631
  static async listCredentialEntries(onChange) {
@@ -556,11 +645,10 @@ var Explorer = class {
556
645
  }
557
646
  }
558
647
  /**
559
- * Returns the recently accessed objects and optionally observes their changes.
648
+ * Returns the recently accessed database objects.
560
649
  *
561
- * The returned tuple contains a cleanup function and the current recent
562
- * entries. The cleanup function removes the registered change listener,
563
- * if any.
650
+ * Optionally registers a listener for changes to the recent entries.
651
+ * The returned cleanup function removes the registered listener.
564
652
  *
565
653
  * @param onChange An optional callback invoked when the entries change.
566
654
  * @returns The cleanup function and recent entries.
@@ -579,9 +667,9 @@ var Explorer = class {
579
667
  }
580
668
  }
581
669
  /**
582
- * Loads an encrypted object using its identifier and cipher key.
670
+ * Loads an encrypted database object by its identifier.
583
671
  *
584
- * Caches the cipher key if the identifier has not been encountered before.
672
+ * Caches the supplied cipher key if no key has been cached for the object.
585
673
  *
586
674
  * @typeParam T The expected type of the loaded object.
587
675
  * @param id The identifier of the object to load.
@@ -598,9 +686,9 @@ var Explorer = class {
598
686
  }
599
687
  }
600
688
  /**
601
- * Encrypts and stores an object using its identifier and cipher key.
689
+ * Encrypts and stores a database object by its identifier.
602
690
  *
603
- * Caches the cipher key if the identifier has not been encountered before.
691
+ * Caches the supplied cipher key if no key has been cached for the object.
604
692
  *
605
693
  * @typeParam T The type of the object to store.
606
694
  * @param id The identifier of the object to store.
@@ -617,7 +705,9 @@ var Explorer = class {
617
705
  }
618
706
  }
619
707
  /**
620
- * Deletes a persisted object using its identifier.
708
+ * Deletes a database object by its identifier.
709
+ *
710
+ * Removes the persisted object and its corresponding recent entry.
621
711
  *
622
712
  * @param id The identifier of the object to delete.
623
713
  */
@@ -657,8 +747,9 @@ var Explorer = class {
657
747
  });
658
748
  }
659
749
  /**
660
- * Registers an event listener for the specified event type.
750
+ * Registers an event listener for the specified Explorer event.
661
751
  *
752
+ * @typeParam K The event name that determines the event detail type.
662
753
  * @param type The event type.
663
754
  * @param listener The event listener, or `null`.
664
755
  * @param options The event listener options.
@@ -674,6 +765,7 @@ var Explorer = class {
674
765
  * Removes an event listener previously registered with
675
766
  * {@link addEventListener}.
676
767
  *
768
+ * @typeParam K The event name that determines the listener type.
677
769
  * @param type The event type.
678
770
  * @param listener The event listener to remove, or `null`.
679
771
  * @param options The event listener options.
@@ -686,8 +778,9 @@ var Explorer = class {
686
778
  }
687
779
  }
688
780
  /**
689
- * Dispatches an event to the registered listeners.
781
+ * Dispatches an Explorer event to registered listeners.
690
782
  *
783
+ * @typeParam K The event name that determines the event detail type.
691
784
  * @param type The event type.
692
785
  * @param detail The value exposed through `CustomEvent.detail`.
693
786
  */
@@ -707,6 +800,7 @@ var Explorer = class {
707
800
  static isExplorerDelta(data) {
708
801
  return Array.isArray(data) && typeof data[0] === "string";
709
802
  }
803
+ /** The cached recent entries list. */
710
804
  static recentEntries;
711
805
  /**
712
806
  * Loads or returns the cached recent entries list.
@@ -730,6 +824,7 @@ var Explorer = class {
730
824
  throw unknownError(cause, "Explorer.loadRecentEntries()");
731
825
  }
732
826
  }
827
+ /** The cached credential entries list. */
733
828
  static credentialEntries;
734
829
  /**
735
830
  * Loads or returns the cached credential entries list.
@@ -747,13 +842,13 @@ var Explorer = class {
747
842
  const id = await Cryptographic.identifier.derive(this.databaseSecret, Bytes.utf8.decode("@sovereignbase/explorer:CredentialEntriesID"), 32);
748
843
  const ck = await Cryptographic.cipherMessage.deriveKey(this.databaseSecret, Bytes.utf8.decode("@sovereignbase/explorer:CredentialEntriesCipherKey"));
749
844
  const trustedSnapshot = await this.loadObjectByID(id, ck, (snapshot) => void this.credentialEntries.merge(snapshot));
750
- this.credentialEntries = new CredentialEntries(id, ck, this.sessionCredential.id, 0, trustedSnapshot);
845
+ this.credentialEntries = new CredentialEntries(id, ck, this.sessionCredential.credentialID, 0, trustedSnapshot);
751
846
  return this.credentialEntries;
752
847
  } catch (cause) {
753
848
  throw unknownError(cause, "Explorer.loadCredentialEntries()");
754
849
  }
755
850
  }
756
851
  };
757
- export { Explorer };
852
+ export { Explorer, ExplorerError };
758
853
 
759
854
  //# sourceMappingURL=class.js.map