@nice-code/util 0.24.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 +111 -5
- package/build/index.cjs.map +1 -1
- package/build/index.d.cts +104 -6
- package/build/index.d.mts +104 -6
- package/build/index.mjs +111 -6
- package/build/index.mjs.map +1 -1
- package/package.json +1 -1
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
|
|
507
|
-
*
|
|
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
|
|
525
|
-
*
|
|
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;
|