@nice-code/util 0.25.0 → 0.26.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/build/index.cjs CHANGED
@@ -452,17 +452,30 @@ const serializeX25519Key_Raw = async (key) => {
452
452
  };
453
453
  //#endregion
454
454
  //#region src/crypto/client_key_link/ClientCryptoKeyLink.ts
455
+ /**
456
+ * Thrown by the local-key getters when the link is in {@link TIdentityMode} `"required"` and no
457
+ * identity has been provisioned. Surfacing this (instead of silently minting a fresh identity on a
458
+ * storage miss) is what keeps a shared backend identity from forking across isolates.
459
+ */
460
+ var IdentityNotProvisionedError = class extends Error {
461
+ constructor(message = "ClientCryptoKeyLink: identity not provisioned (identityMode 'required'). Call provisionIdentity() once, out-of-band, before serving.") {
462
+ super(message);
463
+ this.name = "IdentityNotProvisionedError";
464
+ }
465
+ };
455
466
  var ClientCryptoKeyLink = class {
456
467
  localExchangeKeyPair;
457
468
  localVerifyKeyPair;
458
469
  linkedClientKeys = /* @__PURE__ */ new Map();
459
470
  storage;
471
+ identityMode;
460
472
  initialized = false;
461
473
  initializePromise;
462
474
  localExchangeKeyPairPromise;
463
475
  localVerifyKeyPairPromise;
464
- constructor({ storageAdapter } = {}) {
476
+ constructor({ storageAdapter, identityMode } = {}) {
465
477
  if (storageAdapter != null) this.storage = createTypedStorage({ storageAdapter });
478
+ this.identityMode = identityMode ?? "lazy";
466
479
  }
467
480
  /**
468
481
  * Loads the local key pairs and any linked client public keys from storage (when a storage
@@ -485,6 +498,24 @@ var ClientCryptoKeyLink = class {
485
498
  this.initialized = true;
486
499
  }
487
500
  /**
501
+ * Provision the local identity once: load any persisted identity, then mint + persist the exchange
502
+ * and verify key pairs only if absent. Idempotent — a no-op when an identity already exists.
503
+ *
504
+ * This is the *only* way to mint when {@link TIdentityMode} is `"required"`. Run it from a single
505
+ * coordinated writer (a deploy hook, a first-boot path, a locked admin route) — **never on the hot
506
+ * request path** — because concurrent provisioning over an eventually-consistent store could read
507
+ * "absent" on two isolates at once and fork divergent identities. The serving path uses `"required"`,
508
+ * which never mints, so a transient storage miss fails that one request loudly instead of forking.
509
+ *
510
+ * A genuine storage *read error* propagates here (it is never coerced to "absent"), so an outage
511
+ * surfaces rather than silently minting a replacement identity.
512
+ */
513
+ async provisionIdentity() {
514
+ await this.initialize();
515
+ await this.mintLocalExchangeKeyPair();
516
+ await this.mintLocalVerifyKeyPair();
517
+ }
518
+ /**
488
519
  * Loads the local key pairs from storage if they were previously persisted. Does NOT generate
489
520
  * fresh keys — local identity is created lazily on first use (see {@link ensureLocalExchangeKeyPair}
490
521
  * / {@link ensureLocalVerifyKeyPair}), so a verify-only or otherwise key-less consumer never
@@ -503,10 +534,21 @@ var ClientCryptoKeyLink = class {
503
534
  };
504
535
  }
505
536
  /**
506
- * Returns the local exchange (X25519) key pair, generating and persisting it on first use.
507
- * Concurrent callers share a single generation.
537
+ * Returns the local exchange (X25519) key pair. In `"lazy"` mode it generates + persists it on first
538
+ * use; in `"required"` mode it throws {@link IdentityNotProvisionedError} when none was loaded (mint
539
+ * only via {@link provisionIdentity}). The read path of every encrypt/derive operation.
508
540
  */
509
541
  async ensureLocalExchangeKeyPair() {
542
+ if (this.localExchangeKeyPair != null) return this.localExchangeKeyPair;
543
+ if (this.identityMode === "required") throw new IdentityNotProvisionedError();
544
+ return this.mintLocalExchangeKeyPair();
545
+ }
546
+ /**
547
+ * Generates + persists the local exchange key pair if absent, returning an already-loaded pair
548
+ * untouched (so provisioning is idempotent). Bypasses the `"required"` guard by design — it is the
549
+ * sole minting path. Concurrent callers share a single generation.
550
+ */
551
+ async mintLocalExchangeKeyPair() {
510
552
  if (this.localExchangeKeyPair != null) return this.localExchangeKeyPair;
511
553
  this.localExchangeKeyPairPromise ??= (async () => {
512
554
  const keyPair = await generateX25519KeyPair();
@@ -521,10 +563,21 @@ var ClientCryptoKeyLink = class {
521
563
  }
522
564
  }
523
565
  /**
524
- * Returns the local verify (Ed25519) key pair, generating and persisting it on first use.
525
- * Concurrent callers share a single generation.
566
+ * Returns the local verify (Ed25519) key pair. In `"lazy"` mode it generates + persists it on first
567
+ * use; in `"required"` mode it throws {@link IdentityNotProvisionedError} when none was loaded (mint
568
+ * only via {@link provisionIdentity}). The read path of every sign operation.
526
569
  */
527
570
  async ensureLocalVerifyKeyPair() {
571
+ if (this.localVerifyKeyPair != null) return this.localVerifyKeyPair;
572
+ if (this.identityMode === "required") throw new IdentityNotProvisionedError();
573
+ return this.mintLocalVerifyKeyPair();
574
+ }
575
+ /**
576
+ * Generates + persists the local verify key pair if absent, returning an already-loaded pair
577
+ * untouched (so provisioning is idempotent). Bypasses the `"required"` guard by design — it is the
578
+ * sole minting path. Concurrent callers share a single generation.
579
+ */
580
+ async mintLocalVerifyKeyPair() {
528
581
  if (this.localVerifyKeyPair != null) return this.localVerifyKeyPair;
529
582
  this.localVerifyKeyPairPromise ??= (async () => {
530
583
  const keyPair = await generateEd25519KeyPair();
@@ -742,6 +795,58 @@ var ClientCryptoKeyLink = class {
742
795
  });
743
796
  return sharedEncryptKey;
744
797
  }
798
+ /**
799
+ * Derive the shared AES-GCM key from *explicit* key material + this link's local exchange private
800
+ * key, returning a standalone key that is **not** stored in (or read from) the per-`linkedClientId`
801
+ * cache. Use it to give a single session its own immutable key: two sessions that share one
802
+ * `linkedClientId` (e.g. a secure WebSocket and a secure HTTP exchange to the same peer) would
803
+ * otherwise clobber each other's cached shared key — the second handshake's re-link drops the
804
+ * first's key, so frames on the other transport fail to decrypt. Mirrors the derivation in
805
+ * {@link getAesGcmKeyForLinkedClient} exactly, so the resulting key matches the peer's.
806
+ *
807
+ * Requires the local identity to be loaded — call {@link initialize} first when resuming a link
808
+ * cold (the handshake paths have already initialized by the time they hold key material).
809
+ */
810
+ async deriveSharedAesGcmKey({ exchangePublicKey, saltString, infoString, bindVerifyKeysIntoDerivation, verifyPublicKey }) {
811
+ const localExchangeKeyPair = await this.ensureLocalExchangeKeyPair();
812
+ const externalX25519PublicKey = await importX25519Key.public.fromFormattedString.extractable(exchangePublicKey);
813
+ let derivedInfoString = infoString;
814
+ if (bindVerifyKeysIntoDerivation === true) {
815
+ if (verifyPublicKey == null) throw new Error("ClientCryptoKeyLink.deriveSharedAesGcmKey: a verify public key is required when binding verify keys into the derivation");
816
+ derivedInfoString = buildVerifyKeyBoundInfoString({
817
+ infoString,
818
+ verifyPublicKeys: [await this.getLocalVerifyPublicKey(), verifyPublicKey]
819
+ });
820
+ }
821
+ return await createAesGcmKeyFromX25519Keys({
822
+ internalX25519PrivateKey: localExchangeKeyPair.privateKey,
823
+ externalX25519PublicKey,
824
+ saltString,
825
+ infoString: derivedInfoString
826
+ });
827
+ }
828
+ /**
829
+ * Derive a symmetric AES-GCM key bound to **this link's own identity** — for sealing data the server
830
+ * hands a client and reads back later (e.g. a stateless secure-exchange session ticket), where only
831
+ * this server (any isolate that loaded the same persisted identity) can open it.
832
+ *
833
+ * Deterministic: an X25519 ECDH of the local exchange private key against its own public key, fed
834
+ * through the same vetted HKDF as peer key derivation, with a fixed versioned info label. So every
835
+ * isolate sharing the persisted identity derives the identical key, and no raw private-key bytes are
836
+ * exposed. The {@link version} is part of the label, so a future rotation can run two keys during a
837
+ * grace window (old tickets opened with `v1` while new ones issue under `v2`).
838
+ *
839
+ * Requires the local identity (call {@link initialize} / {@link provisionIdentity} first); in
840
+ * `"required"` mode an unprovisioned link throws {@link IdentityNotProvisionedError}.
841
+ */
842
+ async deriveLocalSealKey(options) {
843
+ const localExchangeKeyPair = await this.ensureLocalExchangeKeyPair();
844
+ return await createAesGcmKeyFromX25519Keys({
845
+ internalX25519PrivateKey: localExchangeKeyPair.privateKey,
846
+ externalX25519PublicKey: localExchangeKeyPair.publicKey,
847
+ infoString: `exchange-ticket-seal/${options?.version ?? "v1"}`
848
+ });
849
+ }
745
850
  async encryptDataForLinkedClient({ dataToEncrypt, linkedClientId }) {
746
851
  return await encryptTextDataWithAesGcmKey({
747
852
  dataToEncrypt,
@@ -1074,6 +1179,7 @@ exports.DEFAULT_COMBINED_TEXT_DATA_SEPARATOR = DEFAULT_COMBINED_TEXT_DATA_SEPARA
1074
1179
  exports.ECryptoKeyAlgo = ECryptoKeyAlgo;
1075
1180
  exports.ECryptoKeyFormat = ECryptoKeyFormat;
1076
1181
  exports.EStorageAdapterType = EStorageAdapterType;
1182
+ exports.IdentityNotProvisionedError = IdentityNotProvisionedError;
1077
1183
  exports.StorageAdapter = StorageAdapter;
1078
1184
  exports.buildVerifyKeyBoundInfoString = buildVerifyKeyBoundInfoString;
1079
1185
  exports.convertEd25519FormattedStringToObject = convertEd25519FormattedStringToObject;