@btravstack/keys 0.0.0-stage → 0.44.1

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Benoit TRAVERS
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,102 @@
1
- # Temporary Holding Version
1
+ # @btravstack/keys
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ > An `EnvelopeKeys` port for [`@btravstack/core`](../core): a root key per
4
+ > owner, in generations, wrapped by an AWS KMS key of that owner — unwrapped in
5
+ > memory and cached for a TTL, erased with one key deletion — and an HKDF
6
+ > helper giving each purpose its own key.
7
+
8
+ 📖 **[Documentation](https://btravstack.github.io/btravstack/reference/keys)** ·
9
+ [API Reference](https://btravstack.github.io/btravstack/api/keys/)
10
+
11
+ ```sh
12
+ pnpm add @btravstack/keys @btravstack/core @btravstack/config @btravstack/di unthrown
13
+ pnpm add @aws-sdk/client-kms # for @btravstack/keys/kms
14
+ ```
15
+
16
+ Every dependency is a peer, and `@aws-sdk/client-kms` an optional one: only
17
+ `@btravstack/keys/kms` imports it.
18
+
19
+ ## A worked example
20
+
21
+ <!-- doctest: prelude
22
+ import { Module, Port, Provider } from "@btravstack/di";
23
+ import type { AsyncResult } from "unthrown";
24
+ -->
25
+
26
+ A port that seals a tenant's payloads under a key of that tenant's own, and
27
+ the composition that provides the keys:
28
+
29
+ ```ts
30
+ import {
31
+ EnvelopeKeys,
32
+ deriveKey,
33
+ type InvalidKeyPurpose,
34
+ type KeyErased,
35
+ type KeyGeneration,
36
+ type KeysUnavailable,
37
+ } from "@btravstack/keys";
38
+ import { kmsKeys } from "@btravstack/keys/kms";
39
+
40
+ class PayloadKeys extends Port("ReadmePayloadKeys")<{
41
+ readonly key: (
42
+ generation: KeyGeneration,
43
+ ) => AsyncResult<CryptoKey, KeyErased | KeysUnavailable | InvalidKeyPurpose>;
44
+ }> {}
45
+
46
+ export const payloadKeys = Provider(PayloadKeys)({
47
+ inject: { keys: EnvelopeKeys },
48
+ sync: ({ keys }) => ({
49
+ key: (generation) =>
50
+ keys.rootKey(generation).flatMap((root) =>
51
+ deriveKey(root, { namespace: "payloads", purpose: "seal", version: 1 }, {
52
+ algorithm: { name: "AES-GCM", length: 256 },
53
+ usages: ["encrypt", "decrypt"],
54
+ }),
55
+ ),
56
+ }),
57
+ });
58
+
59
+ export const Sealing = Module("ReadmeSealing")({
60
+ imports: [kmsKeys()],
61
+ provides: [payloadKeys],
62
+ exports: [PayloadKeys],
63
+ });
64
+ ```
65
+
66
+ Provisioning a generation, rotating, erasing a tenant in one call and sizing
67
+ the cache are
68
+ [Keep envelope keys per owner](https://btravstack.github.io/btravstack/how-to/keep-envelope-keys-per-owner).
69
+
70
+ ## Options
71
+
72
+ | Option | Where | What it is |
73
+ | ------------------------------ | ------------------------------------------ | --------------------------------------------------------- |
74
+ | `KEYS_KMS_REGION` | environment | the region of the owners' KMS keys — required |
75
+ | `KEYS_KMS_ALIAS_PREFIX` | environment | an owner's KMS key is `<prefix><owner>` — required |
76
+ | `KEYS_KMS_ENDPOINT` | environment, pinned by `endpoint` | an emulator or a VPC endpoint (default: AWS's own) |
77
+ | `KEYS_CACHE_TTL_MS` | environment, pinned by `cacheTtlMs` | how long an unwrapped root is kept (default five minutes) |
78
+ | `KEYS_KMS_PENDING_WINDOW_DAYS` | environment, pinned by `pendingWindowDays` | the days an erasure can be cancelled (default `30`) |
79
+ | `clock` | `kmsKeys({ … })` | what the cache's TTL is measured against |
80
+
81
+ The full table — defaults, semantics and the reasoning — lives on
82
+ [the reference page](https://btravstack.github.io/btravstack/reference/keys),
83
+ which is this list's one detailed home.
84
+
85
+ ## What it decides, and what it does not
86
+
87
+ **It decides** that a generation is a KMS data key bound by the encryption
88
+ context `{ owner, keyId }`, so a blob presented for another owner or key id is
89
+ refused rather than unwrapped; that a disabled, deleted, missing or refusing
90
+ key is a typed `KeyErased` and anything else KMS says a typed
91
+ `KeysUnavailable`; that erasing an owner is one scheduled key deletion; that
92
+ every KMS request is reported to `Observers`; and that a derived key is
93
+ HKDF-SHA-256 with `info = <namespace>/<purpose>/v<version>`.
94
+
95
+ **It does not decide** where the wrapped generations are kept, which one
96
+ encrypts next, or how the owners' KMS keys are created: those are the
97
+ application's and the platform's. The reasoning is in
98
+ [`AGENTS.md`](./AGENTS.md).
99
+
100
+ ## License
101
+
102
+ MIT
package/dist/index.cjs ADDED
@@ -0,0 +1,42 @@
1
+ Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
2
+ const require_keys = require("./keys-DtMnWJHq.cjs");
3
+ let unthrown = require("unthrown");
4
+ //#region src/derive.ts
5
+ /** A purpose whose `info` would be ambiguous: a segment outside the alphabet, or a version that is not a positive integer. */
6
+ var InvalidKeyPurpose = class extends (0, unthrown.TaggedError)("InvalidKeyPurpose") {
7
+ message = "the key purpose is not a valid HKDF info";
8
+ };
9
+ const SEGMENT = /^[A-Za-z0-9._-]+$/;
10
+ /**
11
+ * A key for one purpose, derived from a root key with HKDF-SHA-256, an empty
12
+ * salt and `info = <namespace>/<purpose>/v<version>`.
13
+ *
14
+ * ```ts
15
+ * keys.rootKey(generation).flatMap((root) =>
16
+ * deriveKey(root, { namespace: "payloads", purpose: "seal", version: 1 }, {
17
+ * algorithm: { name: "AES-GCM", length: 256 },
18
+ * usages: ["encrypt", "decrypt"],
19
+ * }),
20
+ * );
21
+ * ```
22
+ *
23
+ * Every purpose gets its own key, and so its own nonce budget: two uses of
24
+ * random 96-bit AES-GCM nonces never draw from one key. The salt is empty
25
+ * because the root is already uniformly random (RFC 5869, section 3.1).
26
+ */
27
+ const deriveKey = (root, purpose, derived) => SEGMENT.test(purpose.namespace) && SEGMENT.test(purpose.purpose) && Number.isSafeInteger(purpose.version) && purpose.version > 0 ? (0, unthrown.fromSafePromise)(crypto.subtle.deriveKey({
28
+ name: "HKDF",
29
+ hash: "SHA-256",
30
+ salt: /* @__PURE__ */ new Uint8Array(0),
31
+ info: new TextEncoder().encode(`${purpose.namespace}/${purpose.purpose}/v${purpose.version}`)
32
+ }, root, derived.algorithm, false, [...derived.usages])) : (0, unthrown.ErrAsync)(new InvalidKeyPurpose({
33
+ namespace: purpose.namespace,
34
+ purpose: purpose.purpose,
35
+ version: purpose.version
36
+ }));
37
+ //#endregion
38
+ exports.EnvelopeKeys = require_keys.EnvelopeKeys;
39
+ exports.InvalidKeyPurpose = InvalidKeyPurpose;
40
+ exports.KeyErased = require_keys.KeyErased;
41
+ exports.KeysUnavailable = require_keys.KeysUnavailable;
42
+ exports.deriveKey = deriveKey;
@@ -0,0 +1,49 @@
1
+ import { a as KeysUnavailable, i as KeyGeneration, n as EnvelopeKeysService, r as KeyErased, t as EnvelopeKeys } from "./keys-DpvmIcWy.cjs";
2
+ import { webcrypto } from "node:crypto";
3
+ import { AsyncResult } from "unthrown";
4
+ //#region src/derive.d.ts
5
+ /**
6
+ * What a derived key is for, spelled into HKDF's `info` as
7
+ * `<namespace>/<purpose>/v<version>`. Each segment is ASCII letters, digits,
8
+ * `.`, `_` or `-`, so no two purposes spell the same `info`; `version` is a
9
+ * positive integer, raised to retire a derived key without a new root.
10
+ */
11
+ type KeyPurpose = {
12
+ readonly namespace: string;
13
+ readonly purpose: string;
14
+ readonly version: number;
15
+ };
16
+ /** What the derived key is, as WebCrypto's `deriveKey` takes it. It is never extractable. */
17
+ type DerivedKey = {
18
+ readonly algorithm: webcrypto.AesDerivedKeyParams | webcrypto.HmacImportParams;
19
+ readonly usages: readonly webcrypto.KeyUsage[];
20
+ };
21
+ declare const InvalidKeyPurpose_base: import("unthrown").TaggedErrorConstructor<"InvalidKeyPurpose">;
22
+ /** A purpose whose `info` would be ambiguous: a segment outside the alphabet, or a version that is not a positive integer. */
23
+ export declare class InvalidKeyPurpose extends InvalidKeyPurpose_base<{
24
+ readonly namespace: string;
25
+ readonly purpose: string;
26
+ readonly version: number;
27
+ }> {
28
+ message: string;
29
+ }
30
+ /**
31
+ * A key for one purpose, derived from a root key with HKDF-SHA-256, an empty
32
+ * salt and `info = <namespace>/<purpose>/v<version>`.
33
+ *
34
+ * ```ts
35
+ * keys.rootKey(generation).flatMap((root) =>
36
+ * deriveKey(root, { namespace: "payloads", purpose: "seal", version: 1 }, {
37
+ * algorithm: { name: "AES-GCM", length: 256 },
38
+ * usages: ["encrypt", "decrypt"],
39
+ * }),
40
+ * );
41
+ * ```
42
+ *
43
+ * Every purpose gets its own key, and so its own nonce budget: two uses of
44
+ * random 96-bit AES-GCM nonces never draw from one key. The salt is empty
45
+ * because the root is already uniformly random (RFC 5869, section 3.1).
46
+ */
47
+ export declare const deriveKey: (root: CryptoKey, purpose: KeyPurpose, derived: DerivedKey) => AsyncResult<CryptoKey, InvalidKeyPurpose>;
48
+ //#endregion
49
+ export { type DerivedKey, EnvelopeKeys, type EnvelopeKeysService, KeyErased, type KeyGeneration, type KeyPurpose, KeysUnavailable };
@@ -0,0 +1,49 @@
1
+ import { a as KeysUnavailable, i as KeyGeneration, n as EnvelopeKeysService, r as KeyErased, t as EnvelopeKeys } from "./keys-DpvmIcWy.mjs";
2
+ import { AsyncResult } from "unthrown";
3
+ import { webcrypto } from "node:crypto";
4
+ //#region src/derive.d.ts
5
+ /**
6
+ * What a derived key is for, spelled into HKDF's `info` as
7
+ * `<namespace>/<purpose>/v<version>`. Each segment is ASCII letters, digits,
8
+ * `.`, `_` or `-`, so no two purposes spell the same `info`; `version` is a
9
+ * positive integer, raised to retire a derived key without a new root.
10
+ */
11
+ type KeyPurpose = {
12
+ readonly namespace: string;
13
+ readonly purpose: string;
14
+ readonly version: number;
15
+ };
16
+ /** What the derived key is, as WebCrypto's `deriveKey` takes it. It is never extractable. */
17
+ type DerivedKey = {
18
+ readonly algorithm: webcrypto.AesDerivedKeyParams | webcrypto.HmacImportParams;
19
+ readonly usages: readonly webcrypto.KeyUsage[];
20
+ };
21
+ declare const InvalidKeyPurpose_base: import("unthrown").TaggedErrorConstructor<"InvalidKeyPurpose">;
22
+ /** A purpose whose `info` would be ambiguous: a segment outside the alphabet, or a version that is not a positive integer. */
23
+ export declare class InvalidKeyPurpose extends InvalidKeyPurpose_base<{
24
+ readonly namespace: string;
25
+ readonly purpose: string;
26
+ readonly version: number;
27
+ }> {
28
+ message: string;
29
+ }
30
+ /**
31
+ * A key for one purpose, derived from a root key with HKDF-SHA-256, an empty
32
+ * salt and `info = <namespace>/<purpose>/v<version>`.
33
+ *
34
+ * ```ts
35
+ * keys.rootKey(generation).flatMap((root) =>
36
+ * deriveKey(root, { namespace: "payloads", purpose: "seal", version: 1 }, {
37
+ * algorithm: { name: "AES-GCM", length: 256 },
38
+ * usages: ["encrypt", "decrypt"],
39
+ * }),
40
+ * );
41
+ * ```
42
+ *
43
+ * Every purpose gets its own key, and so its own nonce budget: two uses of
44
+ * random 96-bit AES-GCM nonces never draw from one key. The salt is empty
45
+ * because the root is already uniformly random (RFC 5869, section 3.1).
46
+ */
47
+ export declare const deriveKey: (root: CryptoKey, purpose: KeyPurpose, derived: DerivedKey) => AsyncResult<CryptoKey, InvalidKeyPurpose>;
48
+ //#endregion
49
+ export { type DerivedKey, EnvelopeKeys, type EnvelopeKeysService, KeyErased, type KeyGeneration, type KeyPurpose, KeysUnavailable };
package/dist/index.mjs ADDED
@@ -0,0 +1,37 @@
1
+ import { n as KeyErased, r as KeysUnavailable, t as EnvelopeKeys } from "./keys-CGbVjuu3.mjs";
2
+ import { ErrAsync, TaggedError, fromSafePromise } from "unthrown";
3
+ //#region src/derive.ts
4
+ /** A purpose whose `info` would be ambiguous: a segment outside the alphabet, or a version that is not a positive integer. */
5
+ var InvalidKeyPurpose = class extends TaggedError("InvalidKeyPurpose") {
6
+ message = "the key purpose is not a valid HKDF info";
7
+ };
8
+ const SEGMENT = /^[A-Za-z0-9._-]+$/;
9
+ /**
10
+ * A key for one purpose, derived from a root key with HKDF-SHA-256, an empty
11
+ * salt and `info = <namespace>/<purpose>/v<version>`.
12
+ *
13
+ * ```ts
14
+ * keys.rootKey(generation).flatMap((root) =>
15
+ * deriveKey(root, { namespace: "payloads", purpose: "seal", version: 1 }, {
16
+ * algorithm: { name: "AES-GCM", length: 256 },
17
+ * usages: ["encrypt", "decrypt"],
18
+ * }),
19
+ * );
20
+ * ```
21
+ *
22
+ * Every purpose gets its own key, and so its own nonce budget: two uses of
23
+ * random 96-bit AES-GCM nonces never draw from one key. The salt is empty
24
+ * because the root is already uniformly random (RFC 5869, section 3.1).
25
+ */
26
+ const deriveKey = (root, purpose, derived) => SEGMENT.test(purpose.namespace) && SEGMENT.test(purpose.purpose) && Number.isSafeInteger(purpose.version) && purpose.version > 0 ? fromSafePromise(crypto.subtle.deriveKey({
27
+ name: "HKDF",
28
+ hash: "SHA-256",
29
+ salt: /* @__PURE__ */ new Uint8Array(0),
30
+ info: new TextEncoder().encode(`${purpose.namespace}/${purpose.purpose}/v${purpose.version}`)
31
+ }, root, derived.algorithm, false, [...derived.usages])) : ErrAsync(new InvalidKeyPurpose({
32
+ namespace: purpose.namespace,
33
+ purpose: purpose.purpose,
34
+ version: purpose.version
35
+ }));
36
+ //#endregion
37
+ export { EnvelopeKeys, InvalidKeyPurpose, KeyErased, KeysUnavailable, deriveKey };
@@ -0,0 +1,29 @@
1
+ import { TaggedError } from "unthrown";
2
+ import { Port } from "@btravstack/di";
3
+ //#region src/keys.ts
4
+ /**
5
+ * The generation can no longer be unwrapped, and never will be again: the
6
+ * owner's key is disabled (`disabled`), scheduled for deletion
7
+ * (`pending_deletion`), gone (`not_found`), or refused this ciphertext under
8
+ * this owner and key id (`refused`). An answer, not an outage: whatever the
9
+ * generation protected is erased.
10
+ */
11
+ var KeyErased = class extends TaggedError("KeyErased") {
12
+ message = "the key generation is erased";
13
+ };
14
+ /**
15
+ * The key-management service could not be asked: throttling, the network, a
16
+ * credential or permission it refused, a server error. Transient or a
17
+ * deployment mistake, never an erasure, so a later call asks again: a failure
18
+ * is never cached.
19
+ */
20
+ var KeysUnavailable = class extends TaggedError("KeysUnavailable") {
21
+ message = "the key-management service could not be asked";
22
+ };
23
+ /**
24
+ * The port an application depends on for per-owner envelope keys.
25
+ * `kmsKeys()` from `@btravstack/keys/kms` provides it over AWS KMS.
26
+ */
27
+ var EnvelopeKeys = class extends Port("EnvelopeKeys") {};
28
+ //#endregion
29
+ export { KeyErased as n, KeysUnavailable as r, EnvelopeKeys as t };
@@ -0,0 +1,74 @@
1
+ import { AsyncResult } from "unthrown";
2
+ //#region src/keys.d.ts
3
+ /**
4
+ * One generation of an owner's root key, as it is kept: wrapped by the
5
+ * owner's key-management key, under an encryption context that binds `owner`
6
+ * and `keyId`.
7
+ *
8
+ * `wrapped` is not secret — only the key-management service can unwrap it, and
9
+ * only for this owner and this key id — so it may live wherever the
10
+ * application keeps configuration: a mounted Secret, a file, an object store,
11
+ * a database row.
12
+ */
13
+ type KeyGeneration = {
14
+ readonly owner: string;
15
+ readonly keyId: string;
16
+ readonly wrapped: Uint8Array;
17
+ };
18
+ declare const KeyErased_base: import("unthrown").TaggedErrorConstructor<"KeyErased">;
19
+ /**
20
+ * The generation can no longer be unwrapped, and never will be again: the
21
+ * owner's key is disabled (`disabled`), scheduled for deletion
22
+ * (`pending_deletion`), gone (`not_found`), or refused this ciphertext under
23
+ * this owner and key id (`refused`). An answer, not an outage: whatever the
24
+ * generation protected is erased.
25
+ */
26
+ declare class KeyErased extends KeyErased_base<{
27
+ readonly owner: string;
28
+ readonly keyId: string;
29
+ readonly reason: "disabled" | "pending_deletion" | "not_found" | "refused";
30
+ }> {
31
+ message: string;
32
+ }
33
+ declare const KeysUnavailable_base: import("unthrown").TaggedErrorConstructor<"KeysUnavailable">;
34
+ /**
35
+ * The key-management service could not be asked: throttling, the network, a
36
+ * credential or permission it refused, a server error. Transient or a
37
+ * deployment mistake, never an erasure, so a later call asks again: a failure
38
+ * is never cached.
39
+ */
40
+ declare class KeysUnavailable extends KeysUnavailable_base<{
41
+ readonly operation: "unwrap" | "provision" | "erase";
42
+ readonly owner: string;
43
+ readonly cause: unknown;
44
+ }> {
45
+ message: string;
46
+ }
47
+ type EnvelopeKeysService = {
48
+ /**
49
+ * The generation's root key, unwrapped in memory: a non-extractable HKDF
50
+ * key, to derive a key per purpose from with `deriveKey`. Kept for the
51
+ * cache's TTL; callers asking while it is being unwrapped share one request.
52
+ */
53
+ readonly rootKey: (generation: KeyGeneration) => AsyncResult<CryptoKey, KeyErased | KeysUnavailable>;
54
+ /**
55
+ * A new generation under the owner's key, for the application to keep.
56
+ * Rotation is provisioning another key id; the old generations stay
57
+ * readable until the owner is erased.
58
+ */
59
+ readonly provision: (owner: string, keyId: string) => AsyncResult<KeyGeneration, KeyErased | KeysUnavailable>;
60
+ /**
61
+ * Every generation of the owner, erased at once: the owner's key is
62
+ * scheduled for deletion, so from then on each generation answers
63
+ * `KeyErased`. Erasing an owner already erased answers `Ok`.
64
+ */
65
+ readonly erase: (owner: string) => AsyncResult<void, KeysUnavailable>;
66
+ };
67
+ declare const EnvelopeKeys_base: import("@btravstack/di").PortClass<"EnvelopeKeys">;
68
+ /**
69
+ * The port an application depends on for per-owner envelope keys.
70
+ * `kmsKeys()` from `@btravstack/keys/kms` provides it over AWS KMS.
71
+ */
72
+ declare class EnvelopeKeys extends EnvelopeKeys_base<EnvelopeKeysService> {}
73
+ //#endregion
74
+ export { KeysUnavailable as a, KeyGeneration as i, EnvelopeKeysService as n, KeyErased as r, EnvelopeKeys as t };
@@ -0,0 +1,74 @@
1
+ import { AsyncResult } from "unthrown";
2
+ //#region src/keys.d.ts
3
+ /**
4
+ * One generation of an owner's root key, as it is kept: wrapped by the
5
+ * owner's key-management key, under an encryption context that binds `owner`
6
+ * and `keyId`.
7
+ *
8
+ * `wrapped` is not secret — only the key-management service can unwrap it, and
9
+ * only for this owner and this key id — so it may live wherever the
10
+ * application keeps configuration: a mounted Secret, a file, an object store,
11
+ * a database row.
12
+ */
13
+ type KeyGeneration = {
14
+ readonly owner: string;
15
+ readonly keyId: string;
16
+ readonly wrapped: Uint8Array;
17
+ };
18
+ declare const KeyErased_base: import("unthrown").TaggedErrorConstructor<"KeyErased">;
19
+ /**
20
+ * The generation can no longer be unwrapped, and never will be again: the
21
+ * owner's key is disabled (`disabled`), scheduled for deletion
22
+ * (`pending_deletion`), gone (`not_found`), or refused this ciphertext under
23
+ * this owner and key id (`refused`). An answer, not an outage: whatever the
24
+ * generation protected is erased.
25
+ */
26
+ declare class KeyErased extends KeyErased_base<{
27
+ readonly owner: string;
28
+ readonly keyId: string;
29
+ readonly reason: "disabled" | "pending_deletion" | "not_found" | "refused";
30
+ }> {
31
+ message: string;
32
+ }
33
+ declare const KeysUnavailable_base: import("unthrown").TaggedErrorConstructor<"KeysUnavailable">;
34
+ /**
35
+ * The key-management service could not be asked: throttling, the network, a
36
+ * credential or permission it refused, a server error. Transient or a
37
+ * deployment mistake, never an erasure, so a later call asks again: a failure
38
+ * is never cached.
39
+ */
40
+ declare class KeysUnavailable extends KeysUnavailable_base<{
41
+ readonly operation: "unwrap" | "provision" | "erase";
42
+ readonly owner: string;
43
+ readonly cause: unknown;
44
+ }> {
45
+ message: string;
46
+ }
47
+ type EnvelopeKeysService = {
48
+ /**
49
+ * The generation's root key, unwrapped in memory: a non-extractable HKDF
50
+ * key, to derive a key per purpose from with `deriveKey`. Kept for the
51
+ * cache's TTL; callers asking while it is being unwrapped share one request.
52
+ */
53
+ readonly rootKey: (generation: KeyGeneration) => AsyncResult<CryptoKey, KeyErased | KeysUnavailable>;
54
+ /**
55
+ * A new generation under the owner's key, for the application to keep.
56
+ * Rotation is provisioning another key id; the old generations stay
57
+ * readable until the owner is erased.
58
+ */
59
+ readonly provision: (owner: string, keyId: string) => AsyncResult<KeyGeneration, KeyErased | KeysUnavailable>;
60
+ /**
61
+ * Every generation of the owner, erased at once: the owner's key is
62
+ * scheduled for deletion, so from then on each generation answers
63
+ * `KeyErased`. Erasing an owner already erased answers `Ok`.
64
+ */
65
+ readonly erase: (owner: string) => AsyncResult<void, KeysUnavailable>;
66
+ };
67
+ declare const EnvelopeKeys_base: import("@btravstack/di").PortClass<"EnvelopeKeys">;
68
+ /**
69
+ * The port an application depends on for per-owner envelope keys.
70
+ * `kmsKeys()` from `@btravstack/keys/kms` provides it over AWS KMS.
71
+ */
72
+ declare class EnvelopeKeys extends EnvelopeKeys_base<EnvelopeKeysService> {}
73
+ //#endregion
74
+ export { KeysUnavailable as a, KeyGeneration as i, EnvelopeKeysService as n, KeyErased as r, EnvelopeKeys as t };
@@ -0,0 +1,46 @@
1
+ let unthrown = require("unthrown");
2
+ let _btravstack_di = require("@btravstack/di");
3
+ //#region src/keys.ts
4
+ /**
5
+ * The generation can no longer be unwrapped, and never will be again: the
6
+ * owner's key is disabled (`disabled`), scheduled for deletion
7
+ * (`pending_deletion`), gone (`not_found`), or refused this ciphertext under
8
+ * this owner and key id (`refused`). An answer, not an outage: whatever the
9
+ * generation protected is erased.
10
+ */
11
+ var KeyErased = class extends (0, unthrown.TaggedError)("KeyErased") {
12
+ message = "the key generation is erased";
13
+ };
14
+ /**
15
+ * The key-management service could not be asked: throttling, the network, a
16
+ * credential or permission it refused, a server error. Transient or a
17
+ * deployment mistake, never an erasure, so a later call asks again: a failure
18
+ * is never cached.
19
+ */
20
+ var KeysUnavailable = class extends (0, unthrown.TaggedError)("KeysUnavailable") {
21
+ message = "the key-management service could not be asked";
22
+ };
23
+ /**
24
+ * The port an application depends on for per-owner envelope keys.
25
+ * `kmsKeys()` from `@btravstack/keys/kms` provides it over AWS KMS.
26
+ */
27
+ var EnvelopeKeys = class extends (0, _btravstack_di.Port)("EnvelopeKeys") {};
28
+ //#endregion
29
+ Object.defineProperty(exports, "EnvelopeKeys", {
30
+ enumerable: true,
31
+ get: function() {
32
+ return EnvelopeKeys;
33
+ }
34
+ });
35
+ Object.defineProperty(exports, "KeyErased", {
36
+ enumerable: true,
37
+ get: function() {
38
+ return KeyErased;
39
+ }
40
+ });
41
+ Object.defineProperty(exports, "KeysUnavailable", {
42
+ enumerable: true,
43
+ get: function() {
44
+ return KeysUnavailable;
45
+ }
46
+ });
package/dist/kms.cjs ADDED
@@ -0,0 +1,211 @@
1
+ Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
2
+ const require_keys = require("./keys-DtMnWJHq.cjs");
3
+ let unthrown = require("unthrown");
4
+ let _btravstack_di = require("@btravstack/di");
5
+ let _aws_sdk_client_kms = require("@aws-sdk/client-kms");
6
+ let _btravstack_config = require("@btravstack/config");
7
+ let _btravstack_core = require("@btravstack/core");
8
+ //#region src/kms.ts
9
+ /** What the graph bound from the environment for the KMS adapter. */
10
+ var KeysConfig = class extends (0, _btravstack_di.Port)("KeysConfig") {};
11
+ /** The client, as a port of its own, so the scope closing destroys it. */
12
+ var KmsConnection = class extends (0, _btravstack_di.Port)("KmsConnection") {};
13
+ const optionalUrl = (variable) => {
14
+ const url = _btravstack_config.Config.url(variable);
15
+ return {
16
+ variable,
17
+ parse: (raw) => raw === void 0 ? (0, unthrown.Ok)(void 0) : url.parse(raw),
18
+ check: (value) => url.parse(String(value))
19
+ };
20
+ };
21
+ /**
22
+ * `EnvelopeKeys` over AWS KMS, with one KMS key per owner, named by the alias
23
+ * `<KEYS_KMS_ALIAS_PREFIX><owner>`.
24
+ *
25
+ * ```ts
26
+ * Module("Documents")({ imports: [kmsKeys()], provides: [sealer] });
27
+ * ```
28
+ *
29
+ * - **Configured from `KEYS_KMS_REGION` and `KEYS_KMS_ALIAS_PREFIX`** (an
30
+ * alias prefix, `alias/tenant-` say), and the pinnable variables of
31
+ * {@link KmsKeysOptions}. Credentials are the AWS SDK's default chain: a
32
+ * workload identity, an instance profile, or `AWS_*` variables.
33
+ * - **A generation is a KMS data key**: `provision` asks
34
+ * `GenerateDataKeyWithoutPlaintext` for an AES-256 key under the owner's
35
+ * KMS key, and `rootKey` unwraps it with `Decrypt`, both with the encryption
36
+ * context `{ owner, keyId }` and the owner's alias as the key, so a blob
37
+ * handed in for another owner or key id is refused rather than unwrapped.
38
+ * - **Erasure is one key deletion**: `erase` resolves the owner's alias and
39
+ * schedules its key's deletion, after which KMS refuses every generation of
40
+ * the owner. This process forgets the keys it kept at once; another
41
+ * process keeps serving its own until they expire, which is what
42
+ * `KEYS_CACHE_TTL_MS` bounds.
43
+ * - **Refusals are answers**: a disabled, deleted, missing or refusing key is
44
+ * `KeyErased`; anything else KMS says is `KeysUnavailable`, never cached.
45
+ * - **Every KMS request is reported to `Observers`** (`component: "keys"`); a
46
+ * key served from memory reports nothing.
47
+ */
48
+ const kmsKeys = (options = {}) => (0, _btravstack_di.Module)("KmsKeys")({
49
+ needs: [_btravstack_config.Env],
50
+ provides: [
51
+ _btravstack_core.noObserverMember,
52
+ _btravstack_config.Config.provider(KeysConfig)(_btravstack_config.Config.object({
53
+ region: _btravstack_config.Config.string("KEYS_KMS_REGION"),
54
+ aliasPrefix: _btravstack_config.Config.string("KEYS_KMS_ALIAS_PREFIX"),
55
+ endpoint: _btravstack_config.Config.pinned(options.endpoint, optionalUrl("KEYS_KMS_ENDPOINT")),
56
+ cacheTtlMs: _btravstack_config.Config.pinned(options.cacheTtlMs, _btravstack_config.Config.integer("KEYS_CACHE_TTL_MS", {
57
+ default: 3e5,
58
+ min: 0
59
+ })),
60
+ pendingWindowDays: _btravstack_config.Config.pinned(options.pendingWindowDays, _btravstack_config.Config.integer("KEYS_KMS_PENDING_WINDOW_DAYS", {
61
+ default: 30,
62
+ min: 7,
63
+ max: 30
64
+ }))
65
+ })),
66
+ (0, _btravstack_di.Provider)(KmsConnection)({
67
+ inject: { config: KeysConfig },
68
+ acquire: ({ config }) => (0, unthrown.Ok)(new _aws_sdk_client_kms.KMSClient({
69
+ region: config.region,
70
+ ...config.endpoint === void 0 ? {} : { endpoint: config.endpoint }
71
+ })),
72
+ release: (client) => client.destroy()
73
+ }),
74
+ (0, _btravstack_di.Provider)(require_keys.EnvelopeKeys)({
75
+ inject: {
76
+ config: KeysConfig,
77
+ client: KmsConnection,
78
+ observers: _btravstack_core.Observers
79
+ },
80
+ sync: ({ config, client, observers }) => kmsEnvelopeKeys(client, config, observers, options.clock ?? _btravstack_core.systemClock)
81
+ })
82
+ ],
83
+ exports: [require_keys.EnvelopeKeys]
84
+ });
85
+ /** The KMS errors that mean the generation will never be unwrapped again, and the reason each one gives. */
86
+ const ERASED = /* @__PURE__ */ new Map([
87
+ ["DisabledException", "disabled"],
88
+ ["KMSInvalidStateException", "pending_deletion"],
89
+ ["NotFoundException", "not_found"],
90
+ ["InvalidCiphertextException", "refused"],
91
+ ["IncorrectKeyException", "refused"]
92
+ ]);
93
+ const GONE = /* @__PURE__ */ new Set(["NotFoundException", "KMSInvalidStateException"]);
94
+ const nameOf = (cause) => Object(cause).name;
95
+ const kmsEnvelopeKeys = (client, config, observers, clock) => {
96
+ const cached = /* @__PURE__ */ new Map();
97
+ const inFlight = /* @__PURE__ */ new Map();
98
+ const aliasOf = (owner) => `${config.aliasPrefix}${owner}`;
99
+ const through = (name, owner, keyId, call) => (0, _btravstack_core.observed)(observers, {
100
+ component: "keys",
101
+ name,
102
+ attributes: { operation: name },
103
+ details: {
104
+ "btravstack.keys.owner": owner,
105
+ ...keyId === void 0 ? {} : { "btravstack.keys.key_id": keyId }
106
+ }
107
+ }, call, {
108
+ ok: () => ({
109
+ outcome: "ok",
110
+ attributes: { result: "ok" }
111
+ }),
112
+ failure: settled
113
+ });
114
+ const request = (operation, generation, send) => (0, unthrown.fromPromise)(send(), (cause) => {
115
+ const reason = ERASED.get(nameOf(cause));
116
+ return reason === void 0 ? new require_keys.KeysUnavailable({
117
+ operation,
118
+ owner: generation.owner,
119
+ cause
120
+ }) : new require_keys.KeyErased({
121
+ owner: generation.owner,
122
+ keyId: generation.keyId,
123
+ reason
124
+ });
125
+ });
126
+ const unwrap = (entry, generation) => {
127
+ const { owner, keyId } = generation;
128
+ const unwrapping = through("unwrap", owner, keyId, () => request("unwrap", generation, () => client.send(new _aws_sdk_client_kms.DecryptCommand({
129
+ CiphertextBlob: generation.wrapped,
130
+ KeyId: aliasOf(owner),
131
+ EncryptionContext: {
132
+ owner,
133
+ keyId
134
+ }
135
+ }))).flatMap(({ Plaintext }) => {
136
+ const plaintext = Plaintext;
137
+ return (0, unthrown.fromSafePromise)(crypto.subtle.importKey("raw", plaintext, "HKDF", false, ["deriveKey", "deriveBits"]).finally(() => plaintext.fill(0)));
138
+ })).tap((root) => {
139
+ inFlight.delete(entry);
140
+ const now = clock.now();
141
+ for (const [key, kept] of cached) if (kept.expiresAt <= now) cached.delete(key);
142
+ cached.set(entry, {
143
+ owner,
144
+ root,
145
+ expiresAt: now + config.cacheTtlMs
146
+ });
147
+ }).tapFailure(() => inFlight.delete(entry));
148
+ inFlight.set(entry, unwrapping);
149
+ return unwrapping;
150
+ };
151
+ return {
152
+ rootKey: (generation) => {
153
+ const entry = JSON.stringify([
154
+ generation.owner,
155
+ generation.keyId,
156
+ Buffer.from(generation.wrapped).toString("base64")
157
+ ]);
158
+ const hit = cached.get(entry);
159
+ if (hit !== void 0 && clock.now() < hit.expiresAt) return (0, unthrown.OkAsync)(hit.root);
160
+ cached.delete(entry);
161
+ return inFlight.get(entry) ?? unwrap(entry, generation);
162
+ },
163
+ provision: (owner, keyId) => through("provision", owner, keyId, () => request("provision", {
164
+ owner,
165
+ keyId
166
+ }, () => client.send(new _aws_sdk_client_kms.GenerateDataKeyWithoutPlaintextCommand({
167
+ KeyId: aliasOf(owner),
168
+ KeySpec: "AES_256",
169
+ EncryptionContext: {
170
+ owner,
171
+ keyId
172
+ }
173
+ }))).map(({ CiphertextBlob }) => ({
174
+ owner,
175
+ keyId,
176
+ wrapped: CiphertextBlob
177
+ }))),
178
+ erase: (owner) => {
179
+ const erasing = (cause) => GONE.has(nameOf(cause)) ? "gone" : new require_keys.KeysUnavailable({
180
+ operation: "erase",
181
+ owner,
182
+ cause
183
+ });
184
+ return through("erase", owner, void 0, () => (0, unthrown.fromPromise)(client.send(new _aws_sdk_client_kms.DescribeKeyCommand({ KeyId: aliasOf(owner) })), erasing).flatMap(({ KeyMetadata }) => (0, unthrown.fromPromise)(client.send(new _aws_sdk_client_kms.ScheduleKeyDeletionCommand({
185
+ KeyId: KeyMetadata.KeyId,
186
+ PendingWindowInDays: config.pendingWindowDays
187
+ })), erasing)).map(() => void 0).flatMapErrCases((matcher) => matcher.with("gone", () => (0, unthrown.Ok)(void 0)).with(unthrown.P.tag("KeysUnavailable"), (unavailable) => (0, unthrown.Err)(unavailable)))).tap(() => {
188
+ for (const [key, kept] of cached) if (kept.owner === owner) cached.delete(key);
189
+ });
190
+ }
191
+ };
192
+ };
193
+ /**
194
+ * **An erased generation settles `ok` with `result: "erased"`, not `error`.**
195
+ * Asking for a key that was erased is the answer erasure exists to give, so
196
+ * it is counted rather than written as a fault line; `KeysUnavailable` is
197
+ * what pages somebody.
198
+ */
199
+ const settled = (failure) => failure.tag === "Err" && failure.error._tag === "KeyErased" ? {
200
+ outcome: "ok",
201
+ attributes: {
202
+ result: "erased",
203
+ reason: failure.error.reason
204
+ }
205
+ } : {
206
+ outcome: "error",
207
+ attributes: { result: "error" },
208
+ cause: failure.tag === "Err" ? failure.error : failure.cause
209
+ };
210
+ //#endregion
211
+ exports.kmsKeys = kmsKeys;
package/dist/kms.d.cts ADDED
@@ -0,0 +1,60 @@
1
+ import { t as EnvelopeKeys } from "./keys-DpvmIcWy.cjs";
2
+ import { ConfigInvalid, EnvReading } from "@btravstack/config";
3
+ import { Clock } from "@btravstack/core";
4
+ import { Module, Scope } from "@btravstack/di";
5
+ //#region src/kms.d.ts
6
+ /** What {@link kmsKeys} is handed. Each value option pins the variable named beside it. */
7
+ export type KmsKeysOptions = {
8
+ /**
9
+ * Pins `KEYS_KMS_ENDPOINT` (default: AWS's own endpoint for the region): a
10
+ * KMS-compatible emulator, or a VPC endpoint.
11
+ */
12
+ readonly endpoint?: string;
13
+ /**
14
+ * Pins `KEYS_CACHE_TTL_MS` (default `300000`, five minutes): how long an
15
+ * unwrapped root key is kept. It bounds two things at once: the KMS
16
+ * requests, one per generation per process per TTL, and how long a process
17
+ * that did not erase an owner keeps serving that owner's keys after it was
18
+ * erased. `0` keeps nothing.
19
+ */
20
+ readonly cacheTtlMs?: number;
21
+ /**
22
+ * Pins `KEYS_KMS_PENDING_WINDOW_DAYS` (default `30`, AWS's own, between 7
23
+ * and 30): the days a scheduled deletion can still be cancelled. Every
24
+ * generation answers `KeyErased` from the moment it is scheduled.
25
+ */
26
+ readonly pendingWindowDays?: number;
27
+ /** What the cache's TTL is measured against (default: the kernel's `systemClock`). */
28
+ readonly clock?: Clock;
29
+ };
30
+ /** The variables {@link kmsKeys} reads: two required, the rest defaulted or pinnable. */
31
+ export type KmsKeysEnv = EnvReading<"KEYS_KMS_REGION" | "KEYS_KMS_ALIAS_PREFIX", "KEYS_KMS_ENDPOINT" | "KEYS_CACHE_TTL_MS" | "KEYS_KMS_PENDING_WINDOW_DAYS">;
32
+ /**
33
+ * `EnvelopeKeys` over AWS KMS, with one KMS key per owner, named by the alias
34
+ * `<KEYS_KMS_ALIAS_PREFIX><owner>`.
35
+ *
36
+ * ```ts
37
+ * Module("Documents")({ imports: [kmsKeys()], provides: [sealer] });
38
+ * ```
39
+ *
40
+ * - **Configured from `KEYS_KMS_REGION` and `KEYS_KMS_ALIAS_PREFIX`** (an
41
+ * alias prefix, `alias/tenant-` say), and the pinnable variables of
42
+ * {@link KmsKeysOptions}. Credentials are the AWS SDK's default chain: a
43
+ * workload identity, an instance profile, or `AWS_*` variables.
44
+ * - **A generation is a KMS data key**: `provision` asks
45
+ * `GenerateDataKeyWithoutPlaintext` for an AES-256 key under the owner's
46
+ * KMS key, and `rootKey` unwraps it with `Decrypt`, both with the encryption
47
+ * context `{ owner, keyId }` and the owner's alias as the key, so a blob
48
+ * handed in for another owner or key id is refused rather than unwrapped.
49
+ * - **Erasure is one key deletion**: `erase` resolves the owner's alias and
50
+ * schedules its key's deletion, after which KMS refuses every generation of
51
+ * the owner. This process forgets the keys it kept at once; another
52
+ * process keeps serving its own until they expire, which is what
53
+ * `KEYS_CACHE_TTL_MS` bounds.
54
+ * - **Refusals are answers**: a disabled, deleted, missing or refusing key is
55
+ * `KeyErased`; anything else KMS says is `KeysUnavailable`, never cached.
56
+ * - **Every KMS request is reported to `Observers`** (`component: "keys"`); a
57
+ * key served from memory reports nothing.
58
+ */
59
+ export declare const kmsKeys: (options?: KmsKeysOptions) => Module<EnvelopeKeys, ConfigInvalid, KmsKeysEnv | Scope>;
60
+ //#endregion
package/dist/kms.d.mts ADDED
@@ -0,0 +1,60 @@
1
+ import { t as EnvelopeKeys } from "./keys-DpvmIcWy.mjs";
2
+ import { Module, Scope } from "@btravstack/di";
3
+ import { ConfigInvalid, EnvReading } from "@btravstack/config";
4
+ import { Clock } from "@btravstack/core";
5
+ //#region src/kms.d.ts
6
+ /** What {@link kmsKeys} is handed. Each value option pins the variable named beside it. */
7
+ export type KmsKeysOptions = {
8
+ /**
9
+ * Pins `KEYS_KMS_ENDPOINT` (default: AWS's own endpoint for the region): a
10
+ * KMS-compatible emulator, or a VPC endpoint.
11
+ */
12
+ readonly endpoint?: string;
13
+ /**
14
+ * Pins `KEYS_CACHE_TTL_MS` (default `300000`, five minutes): how long an
15
+ * unwrapped root key is kept. It bounds two things at once: the KMS
16
+ * requests, one per generation per process per TTL, and how long a process
17
+ * that did not erase an owner keeps serving that owner's keys after it was
18
+ * erased. `0` keeps nothing.
19
+ */
20
+ readonly cacheTtlMs?: number;
21
+ /**
22
+ * Pins `KEYS_KMS_PENDING_WINDOW_DAYS` (default `30`, AWS's own, between 7
23
+ * and 30): the days a scheduled deletion can still be cancelled. Every
24
+ * generation answers `KeyErased` from the moment it is scheduled.
25
+ */
26
+ readonly pendingWindowDays?: number;
27
+ /** What the cache's TTL is measured against (default: the kernel's `systemClock`). */
28
+ readonly clock?: Clock;
29
+ };
30
+ /** The variables {@link kmsKeys} reads: two required, the rest defaulted or pinnable. */
31
+ export type KmsKeysEnv = EnvReading<"KEYS_KMS_REGION" | "KEYS_KMS_ALIAS_PREFIX", "KEYS_KMS_ENDPOINT" | "KEYS_CACHE_TTL_MS" | "KEYS_KMS_PENDING_WINDOW_DAYS">;
32
+ /**
33
+ * `EnvelopeKeys` over AWS KMS, with one KMS key per owner, named by the alias
34
+ * `<KEYS_KMS_ALIAS_PREFIX><owner>`.
35
+ *
36
+ * ```ts
37
+ * Module("Documents")({ imports: [kmsKeys()], provides: [sealer] });
38
+ * ```
39
+ *
40
+ * - **Configured from `KEYS_KMS_REGION` and `KEYS_KMS_ALIAS_PREFIX`** (an
41
+ * alias prefix, `alias/tenant-` say), and the pinnable variables of
42
+ * {@link KmsKeysOptions}. Credentials are the AWS SDK's default chain: a
43
+ * workload identity, an instance profile, or `AWS_*` variables.
44
+ * - **A generation is a KMS data key**: `provision` asks
45
+ * `GenerateDataKeyWithoutPlaintext` for an AES-256 key under the owner's
46
+ * KMS key, and `rootKey` unwraps it with `Decrypt`, both with the encryption
47
+ * context `{ owner, keyId }` and the owner's alias as the key, so a blob
48
+ * handed in for another owner or key id is refused rather than unwrapped.
49
+ * - **Erasure is one key deletion**: `erase` resolves the owner's alias and
50
+ * schedules its key's deletion, after which KMS refuses every generation of
51
+ * the owner. This process forgets the keys it kept at once; another
52
+ * process keeps serving its own until they expire, which is what
53
+ * `KEYS_CACHE_TTL_MS` bounds.
54
+ * - **Refusals are answers**: a disabled, deleted, missing or refusing key is
55
+ * `KeyErased`; anything else KMS says is `KeysUnavailable`, never cached.
56
+ * - **Every KMS request is reported to `Observers`** (`component: "keys"`); a
57
+ * key served from memory reports nothing.
58
+ */
59
+ export declare const kmsKeys: (options?: KmsKeysOptions) => Module<EnvelopeKeys, ConfigInvalid, KmsKeysEnv | Scope>;
60
+ //#endregion
package/dist/kms.mjs ADDED
@@ -0,0 +1,210 @@
1
+ import { n as KeyErased, r as KeysUnavailable, t as EnvelopeKeys } from "./keys-CGbVjuu3.mjs";
2
+ import { Err, Ok, OkAsync, P, fromPromise, fromSafePromise } from "unthrown";
3
+ import { Module, Port, Provider } from "@btravstack/di";
4
+ import { DecryptCommand, DescribeKeyCommand, GenerateDataKeyWithoutPlaintextCommand, KMSClient, ScheduleKeyDeletionCommand } from "@aws-sdk/client-kms";
5
+ import { Config, Env } from "@btravstack/config";
6
+ import { Observers, noObserverMember, observed, systemClock } from "@btravstack/core";
7
+ //#region src/kms.ts
8
+ /** What the graph bound from the environment for the KMS adapter. */
9
+ var KeysConfig = class extends Port("KeysConfig") {};
10
+ /** The client, as a port of its own, so the scope closing destroys it. */
11
+ var KmsConnection = class extends Port("KmsConnection") {};
12
+ const optionalUrl = (variable) => {
13
+ const url = Config.url(variable);
14
+ return {
15
+ variable,
16
+ parse: (raw) => raw === void 0 ? Ok(void 0) : url.parse(raw),
17
+ check: (value) => url.parse(String(value))
18
+ };
19
+ };
20
+ /**
21
+ * `EnvelopeKeys` over AWS KMS, with one KMS key per owner, named by the alias
22
+ * `<KEYS_KMS_ALIAS_PREFIX><owner>`.
23
+ *
24
+ * ```ts
25
+ * Module("Documents")({ imports: [kmsKeys()], provides: [sealer] });
26
+ * ```
27
+ *
28
+ * - **Configured from `KEYS_KMS_REGION` and `KEYS_KMS_ALIAS_PREFIX`** (an
29
+ * alias prefix, `alias/tenant-` say), and the pinnable variables of
30
+ * {@link KmsKeysOptions}. Credentials are the AWS SDK's default chain: a
31
+ * workload identity, an instance profile, or `AWS_*` variables.
32
+ * - **A generation is a KMS data key**: `provision` asks
33
+ * `GenerateDataKeyWithoutPlaintext` for an AES-256 key under the owner's
34
+ * KMS key, and `rootKey` unwraps it with `Decrypt`, both with the encryption
35
+ * context `{ owner, keyId }` and the owner's alias as the key, so a blob
36
+ * handed in for another owner or key id is refused rather than unwrapped.
37
+ * - **Erasure is one key deletion**: `erase` resolves the owner's alias and
38
+ * schedules its key's deletion, after which KMS refuses every generation of
39
+ * the owner. This process forgets the keys it kept at once; another
40
+ * process keeps serving its own until they expire, which is what
41
+ * `KEYS_CACHE_TTL_MS` bounds.
42
+ * - **Refusals are answers**: a disabled, deleted, missing or refusing key is
43
+ * `KeyErased`; anything else KMS says is `KeysUnavailable`, never cached.
44
+ * - **Every KMS request is reported to `Observers`** (`component: "keys"`); a
45
+ * key served from memory reports nothing.
46
+ */
47
+ const kmsKeys = (options = {}) => Module("KmsKeys")({
48
+ needs: [Env],
49
+ provides: [
50
+ noObserverMember,
51
+ Config.provider(KeysConfig)(Config.object({
52
+ region: Config.string("KEYS_KMS_REGION"),
53
+ aliasPrefix: Config.string("KEYS_KMS_ALIAS_PREFIX"),
54
+ endpoint: Config.pinned(options.endpoint, optionalUrl("KEYS_KMS_ENDPOINT")),
55
+ cacheTtlMs: Config.pinned(options.cacheTtlMs, Config.integer("KEYS_CACHE_TTL_MS", {
56
+ default: 3e5,
57
+ min: 0
58
+ })),
59
+ pendingWindowDays: Config.pinned(options.pendingWindowDays, Config.integer("KEYS_KMS_PENDING_WINDOW_DAYS", {
60
+ default: 30,
61
+ min: 7,
62
+ max: 30
63
+ }))
64
+ })),
65
+ Provider(KmsConnection)({
66
+ inject: { config: KeysConfig },
67
+ acquire: ({ config }) => Ok(new KMSClient({
68
+ region: config.region,
69
+ ...config.endpoint === void 0 ? {} : { endpoint: config.endpoint }
70
+ })),
71
+ release: (client) => client.destroy()
72
+ }),
73
+ Provider(EnvelopeKeys)({
74
+ inject: {
75
+ config: KeysConfig,
76
+ client: KmsConnection,
77
+ observers: Observers
78
+ },
79
+ sync: ({ config, client, observers }) => kmsEnvelopeKeys(client, config, observers, options.clock ?? systemClock)
80
+ })
81
+ ],
82
+ exports: [EnvelopeKeys]
83
+ });
84
+ /** The KMS errors that mean the generation will never be unwrapped again, and the reason each one gives. */
85
+ const ERASED = /* @__PURE__ */ new Map([
86
+ ["DisabledException", "disabled"],
87
+ ["KMSInvalidStateException", "pending_deletion"],
88
+ ["NotFoundException", "not_found"],
89
+ ["InvalidCiphertextException", "refused"],
90
+ ["IncorrectKeyException", "refused"]
91
+ ]);
92
+ const GONE = /* @__PURE__ */ new Set(["NotFoundException", "KMSInvalidStateException"]);
93
+ const nameOf = (cause) => Object(cause).name;
94
+ const kmsEnvelopeKeys = (client, config, observers, clock) => {
95
+ const cached = /* @__PURE__ */ new Map();
96
+ const inFlight = /* @__PURE__ */ new Map();
97
+ const aliasOf = (owner) => `${config.aliasPrefix}${owner}`;
98
+ const through = (name, owner, keyId, call) => observed(observers, {
99
+ component: "keys",
100
+ name,
101
+ attributes: { operation: name },
102
+ details: {
103
+ "btravstack.keys.owner": owner,
104
+ ...keyId === void 0 ? {} : { "btravstack.keys.key_id": keyId }
105
+ }
106
+ }, call, {
107
+ ok: () => ({
108
+ outcome: "ok",
109
+ attributes: { result: "ok" }
110
+ }),
111
+ failure: settled
112
+ });
113
+ const request = (operation, generation, send) => fromPromise(send(), (cause) => {
114
+ const reason = ERASED.get(nameOf(cause));
115
+ return reason === void 0 ? new KeysUnavailable({
116
+ operation,
117
+ owner: generation.owner,
118
+ cause
119
+ }) : new KeyErased({
120
+ owner: generation.owner,
121
+ keyId: generation.keyId,
122
+ reason
123
+ });
124
+ });
125
+ const unwrap = (entry, generation) => {
126
+ const { owner, keyId } = generation;
127
+ const unwrapping = through("unwrap", owner, keyId, () => request("unwrap", generation, () => client.send(new DecryptCommand({
128
+ CiphertextBlob: generation.wrapped,
129
+ KeyId: aliasOf(owner),
130
+ EncryptionContext: {
131
+ owner,
132
+ keyId
133
+ }
134
+ }))).flatMap(({ Plaintext }) => {
135
+ const plaintext = Plaintext;
136
+ return fromSafePromise(crypto.subtle.importKey("raw", plaintext, "HKDF", false, ["deriveKey", "deriveBits"]).finally(() => plaintext.fill(0)));
137
+ })).tap((root) => {
138
+ inFlight.delete(entry);
139
+ const now = clock.now();
140
+ for (const [key, kept] of cached) if (kept.expiresAt <= now) cached.delete(key);
141
+ cached.set(entry, {
142
+ owner,
143
+ root,
144
+ expiresAt: now + config.cacheTtlMs
145
+ });
146
+ }).tapFailure(() => inFlight.delete(entry));
147
+ inFlight.set(entry, unwrapping);
148
+ return unwrapping;
149
+ };
150
+ return {
151
+ rootKey: (generation) => {
152
+ const entry = JSON.stringify([
153
+ generation.owner,
154
+ generation.keyId,
155
+ Buffer.from(generation.wrapped).toString("base64")
156
+ ]);
157
+ const hit = cached.get(entry);
158
+ if (hit !== void 0 && clock.now() < hit.expiresAt) return OkAsync(hit.root);
159
+ cached.delete(entry);
160
+ return inFlight.get(entry) ?? unwrap(entry, generation);
161
+ },
162
+ provision: (owner, keyId) => through("provision", owner, keyId, () => request("provision", {
163
+ owner,
164
+ keyId
165
+ }, () => client.send(new GenerateDataKeyWithoutPlaintextCommand({
166
+ KeyId: aliasOf(owner),
167
+ KeySpec: "AES_256",
168
+ EncryptionContext: {
169
+ owner,
170
+ keyId
171
+ }
172
+ }))).map(({ CiphertextBlob }) => ({
173
+ owner,
174
+ keyId,
175
+ wrapped: CiphertextBlob
176
+ }))),
177
+ erase: (owner) => {
178
+ const erasing = (cause) => GONE.has(nameOf(cause)) ? "gone" : new KeysUnavailable({
179
+ operation: "erase",
180
+ owner,
181
+ cause
182
+ });
183
+ return through("erase", owner, void 0, () => fromPromise(client.send(new DescribeKeyCommand({ KeyId: aliasOf(owner) })), erasing).flatMap(({ KeyMetadata }) => fromPromise(client.send(new ScheduleKeyDeletionCommand({
184
+ KeyId: KeyMetadata.KeyId,
185
+ PendingWindowInDays: config.pendingWindowDays
186
+ })), erasing)).map(() => void 0).flatMapErrCases((matcher) => matcher.with("gone", () => Ok(void 0)).with(P.tag("KeysUnavailable"), (unavailable) => Err(unavailable)))).tap(() => {
187
+ for (const [key, kept] of cached) if (kept.owner === owner) cached.delete(key);
188
+ });
189
+ }
190
+ };
191
+ };
192
+ /**
193
+ * **An erased generation settles `ok` with `result: "erased"`, not `error`.**
194
+ * Asking for a key that was erased is the answer erasure exists to give, so
195
+ * it is counted rather than written as a fault line; `KeysUnavailable` is
196
+ * what pages somebody.
197
+ */
198
+ const settled = (failure) => failure.tag === "Err" && failure.error._tag === "KeyErased" ? {
199
+ outcome: "ok",
200
+ attributes: {
201
+ result: "erased",
202
+ reason: failure.error.reason
203
+ }
204
+ } : {
205
+ outcome: "error",
206
+ attributes: { result: "error" },
207
+ cause: failure.tag === "Err" ? failure.error : failure.cause
208
+ };
209
+ //#endregion
210
+ export { kmsKeys };
package/package.json CHANGED
@@ -1,6 +1,93 @@
1
1
  {
2
2
  "name": "@btravstack/keys",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
3
+ "version": "0.44.1",
4
+ "description": "An EnvelopeKeys port for @btravstack/core: per-owner root keys wrapped by an AWS KMS key of that owner, unwrapped in memory and cached for a TTL, erased with one key deletion, and an HKDF helper giving each purpose its own key",
5
+ "keywords": [
6
+ "crypto-shredding",
7
+ "dependency-injection",
8
+ "envelope-encryption",
9
+ "hkdf",
10
+ "kms",
11
+ "typescript",
12
+ "unthrown"
13
+ ],
14
+ "homepage": "https://github.com/btravstack/btravstack#readme",
15
+ "bugs": {
16
+ "url": "https://github.com/btravstack/btravstack/issues"
17
+ },
18
+ "license": "MIT",
19
+ "author": "Benoit TRAVERS <benoit.travers.fr@gmail.com>",
20
+ "repository": {
21
+ "type": "git",
22
+ "url": "https://github.com/btravstack/btravstack.git",
23
+ "directory": "packages/keys"
24
+ },
25
+ "files": [
26
+ "dist"
27
+ ],
28
+ "type": "module",
29
+ "sideEffects": false,
30
+ "main": "./dist/index.cjs",
31
+ "module": "./dist/index.mjs",
32
+ "types": "./dist/index.d.cts",
33
+ "exports": {
34
+ ".": {
35
+ "import": {
36
+ "types": "./dist/index.d.mts",
37
+ "default": "./dist/index.mjs"
38
+ },
39
+ "require": {
40
+ "types": "./dist/index.d.cts",
41
+ "default": "./dist/index.cjs"
42
+ }
43
+ },
44
+ "./kms": {
45
+ "import": {
46
+ "types": "./dist/kms.d.mts",
47
+ "default": "./dist/kms.mjs"
48
+ },
49
+ "require": {
50
+ "types": "./dist/kms.d.cts",
51
+ "default": "./dist/kms.cjs"
52
+ }
53
+ },
54
+ "./package.json": "./package.json"
55
+ },
56
+ "devDependencies": {
57
+ "@aws-sdk/client-kms": "3.1152.0",
58
+ "@btravstack/config": "0.44.1",
59
+ "@btravstack/core": "0.44.1",
60
+ "@btravstack/di": "0.44.1",
61
+ "@btravstack/internal-test-infra": "0.0.0",
62
+ "@btravstack/testing": "0.44.1",
63
+ "@btravstack/tsconfig": "0.4.0",
64
+ "@types/node": "26.6.5",
65
+ "@unthrown/vitest": "5.12.1",
66
+ "@vitest/coverage-v8": "5.0.3",
67
+ "tsdown": "0.23.1",
68
+ "typescript": "7.0.2",
69
+ "unthrown": "5.12.1",
70
+ "vitest": "5.0.3"
71
+ },
72
+ "peerDependencies": {
73
+ "@aws-sdk/client-kms": "^3.1000.0",
74
+ "@btravstack/config": "^0.44.1",
75
+ "@btravstack/core": "^0.44.1",
76
+ "@btravstack/di": "^0.44.1",
77
+ "unthrown": "^5.0.0"
78
+ },
79
+ "peerDependenciesMeta": {
80
+ "@aws-sdk/client-kms": {
81
+ "optional": true
82
+ }
83
+ },
84
+ "engines": {
85
+ "node": ">=22"
86
+ },
87
+ "scripts": {
88
+ "build": "tsdown src/index.ts src/kms.ts --format cjs,esm --dts --clean",
89
+ "dev": "tsdown src/index.ts src/kms.ts --format cjs,esm --dts --watch",
90
+ "test": "vitest run --coverage",
91
+ "typecheck": "tsc --noEmit && tsc --noEmit -p tsconfig.test-d.json"
92
+ }
6
93
  }