Skip to content

Commit 02ef5e1

Browse files
committed
feat(crypto): enterprise hardening pass — correctness, KMS extensibility, framed §6.8, TS rigor
Harden @adonisjs-lasagna/crypto against the frozen design (01-crypto / 00-foundation) without reshaping the RC surface. All changes are additive (no major bump, contract version unchanged). Correctness & fail-closed (WS-0): - shred: serialize the destructive section (bounded jittered backoff → retriable `shred_in_progress`; Redis-down still fail-open) AND treat shredLive()'s return as authoritative, so a lost race yields `alreadyShredded` with no double WORM audit or duplicate SubjectShredded (§6.6). Ledger-absent check consolidated once before the lock. - rekek: new `shreddedDuringRewrap` bucket so the accounting invariant is exact (scanned = current + rotated + shreddedDuringRewrap + failed). - pg store: INSERT ... RETURNING fails closed (`insert_failed`) instead of a non-null bang. KeyProvider extensibility + SSRF (WS-1): - KeyProvider gains optional `contractVersion`; registry.register() enforces it via assertContractCompat (AI/billing parity). - new HttpKeyProvider base routes every egress through core safeFetch (T13 by construction) + reference VaultKeyProvider (transit engine); raw KEK bytes never enter the process. - new structural guard check-crypto-invariant-11 forbids raw egress outside http_key_provider.ts and requires the base to route through safeFetch (presence FLOOR). - WORM ledger connection name resolved from config (was hardcoded 'backoffice'). Framed enc_v2 stream envelope §6.8 (WS-2): - sealFramedV2 / openFramedV2 (+ streaming variants): a composition of core's per-frame seal, not a new cipher (I1). Frame index rides the authenticated keyId; a counted terminator seals the frame count. Strict-open rejects reorder / drop / duplicate / cross-stream / truncation / tamper and never returns partial plaintext. TypeScript rigor (WS-3): - mixin hooks typed EncryptableModel, decorator options generic <TRow>, command catches narrowed to unknown. Tests & coverage (WS-4): - split the shred spec into the design-named files; add concurrent-race, rekek/worm-ledger accounting, KMS-down, SSRF-blocked, framed-envelope matrix, contractVersion gate, and real-PG crash/governance-absent specs (+ gated real-Vault smoke). - unit coverage floors 84/80/84 → 91/85/90/91 (measured 93.8/87.6/92.6); minMergedCoverage 60/60/60 → 80/80/75; worm_shred_ledger removed from c8 excludes. Docs (WS-5): - fix encryptedColumnCheckSql example drift (positional, not object), Mermaid key hierarchy, rowscope/CHECK callouts, EncryptedRepository controller example, OLD_APP_KEY .env note, Custom-KeyProvider expansion, @searchable post-shred JSDoc, CHANGELOG, tests/README T1..T14 map.
1 parent d046c35 commit 02ef5e1

41 files changed

Lines changed: 2019 additions & 157 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

docs/guides/satellites/crypto.md

Lines changed: 98 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,19 @@ Three layers, so erasure stays cheap and blast radius stays small:
3535
envelope. The ciphertext carries a non-secret `keyId` tag pointing at the
3636
wrapped-DEK row, never the key itself.
3737

38+
```mermaid
39+
flowchart TB
40+
KP["KeyProvider (pluggable)<br/>env · AWS KMS · HashiCorp Vault"]
41+
KEK["KEK — per-tenant Key-Encryption-Key<br/>wraps DEKs only, never encrypts data"]
42+
DEK["DEK — per-(subject × category) Data-Encryption-Key<br/>stored ONLY wrapped, in crypto_wrapped_deks · the sole copy"]
43+
FLD["encrypted field value<br/>enc_v2 sealed under the DEK"]
44+
IDX["blind index (search HMAC)<br/>keyed by a KeyProvider index key, NOT a DEK"]
45+
KP -->|derives| KEK
46+
KEK -->|wraps| DEK
47+
DEK -->|seals| FLD
48+
KP -.->|index key survives a shred| IDX
49+
```
50+
3851
Erasing a subject's data is then just tombstoning its wrapped-DEK row: null the
3952
`wrapped_dek`, and every value sealed under that DEK is permanently unrecoverable
4053
(**I6**). This is crypto-shredding, and it is what makes per-subject erasure a
@@ -170,6 +183,22 @@ const plain = await repo.decrypt(renterId, 'identity', sealed)
170183
const index = await repo.blindIndex('identity', passportNumber) // for a WHERE lookup
171184
```
172185

186+
In a controller the tenant comes from the active request scope, so you never pass it:
187+
188+
```ts
189+
export default class RentersController {
190+
async store({ request }: HttpContext) {
191+
const repo = await app.container.make(EncryptedRepository)
192+
const { id, passportNumber } = request.body()
193+
// Resolved under the current tenant's (subject × category) DEK; fail-closed if
194+
// there is no active tenant scope (never a cross-tenant DEK).
195+
const sealed = await repo.encrypt(id, 'identity', passportNumber)
196+
const index = await repo.blindIndex('identity', passportNumber)
197+
await Renter.create({ id, passportNumber: sealed, passportNumberIndex: index })
198+
}
199+
}
200+
```
201+
173202
Use the decorators for fields that live on a tenant model, and the repository when
174203
the value is not a model column or when you want the encryption call to be
175204
explicit in the code path.
@@ -210,7 +239,12 @@ import { encryptedColumnCheckSql } from '@adonisjs-lasagna/crypto'
210239

211240
export default class extends BaseSchema {
212241
async up() {
213-
this.schema.raw(encryptedColumnCheckSql({ table: 'renters', column: 'passport_number' }))
242+
// Signature is positional: encryptedColumnCheckSql(table, column, options?).
243+
// Run this AFTER the column exists (in the same migration, after createTable, or a
244+
// later one). The constraint name is derived per table+column (`<table>_<column>_is_ciphertext`),
245+
// so it is unique; pass `{ constraintName }` only if the derived name would exceed
246+
// Postgres' 63-char identifier limit.
247+
this.schema.raw(encryptedColumnCheckSql('renters', 'passport_number'))
214248
}
215249
}
216250
```
@@ -267,32 +301,78 @@ drop `OLD_APP_KEY` once no DEK is still wrapped under the old generation. Each
267301
unwrap attempt is a strict open of a DEK envelope, so the read window never
268302
weakens the fail-closed decrypt posture.
269303

304+
```bash
305+
# .env during the rotation window (env provider only)
306+
APP_KEY=<the new key>
307+
OLD_APP_KEY=<the previous key> # remove this once `rekek` reports 0 rows on the old generation
308+
```
309+
310+
The `rekek` summary reports each row as `re-wrapped`, `already current`, or `failed`
311+
(a DEK wrapped under a generation the provider no longer holds — restore it from backup
312+
or re-enter the data). A KMS/Vault provider retains its prior key versions itself, so no
313+
`OLD_APP_KEY` is needed there.
314+
270315
## Custom KeyProvider
271316

272317
The `env` provider is dev-grade: the KEK is a pure function of `APP_KEY`, which
273318
gives destruction granularity but no root-of-trust separation. Production binds a
274-
real KMS. Implement the `KeyProvider` contract, then register it on the
275-
`KeyProviderRegistry` in your own provider and name it in `config.crypto.keyProvider`:
319+
real KMS. The `KeyProvider` contract is small:
320+
321+
```ts
322+
interface KeyProvider {
323+
readonly name: string // the name you put in config.crypto.keyProvider
324+
readonly contractVersion?: number // set = CRYPTO_CONTRACT_VERSION; checked at register time
325+
wrapDek(tenantId, dek): Promise<WrappedDek> // KEK-encrypt a 32-byte DEK
326+
unwrapDek(tenantId, wrapped): Promise<Buffer> // strict; throws on tamper/wrong KEK
327+
currentKekId?(tenantId): Promise<string> // optional rotation cursor for rekek
328+
deriveIndexKey?(tenantId, category): Promise<Buffer> // optional blind-index key (≥32 bytes)
329+
}
330+
```
331+
332+
### HTTP-backed providers (AWS KMS, Vault): extend `HttpKeyProvider`
333+
334+
Any provider that talks to a KMS over HTTP MUST route its outbound through core's
335+
`safeFetch` so a mis-set backend address can never reach loopback / RFC-1918 /
336+
cloud-metadata (T13). Do not call `fetch` yourself — extend `HttpKeyProvider`, whose
337+
`request` / `requestJson` are the pinned egress path. `check-crypto-invariant-11`
338+
enforces that no other crypto code opens a second, unpinned egress.
339+
340+
crypto ships a reference `VaultKeyProvider` (HashiCorp Vault transit engine) built this
341+
way; bind it (or your own subclass) in your provider:
276342

277343
```ts
278344
// providers/kms_provider.ts
279-
import { KeyProviderRegistry } from '@adonisjs-lasagna/crypto'
345+
import { KeyProviderRegistry, VaultKeyProvider } from '@adonisjs-lasagna/crypto'
280346

281347
export default class KmsProvider {
282348
async boot() {
283349
const registry = await this.app.container.make(KeyProviderRegistry)
284-
registry.register(new MyKmsKeyProvider()) // name: 'aws-kms'
350+
// Per-tenant transit key `lasagna-crypto-<tenantId>`; every call is SSRF-pinned.
351+
registry.register(
352+
new VaultKeyProvider({ address: env.get('VAULT_ADDR'), token: env.get('VAULT_TOKEN') })
353+
)
285354
}
286355
}
287356
```
288357

289358
```ts
290359
// config/multitenancy.ts
291-
crypto: defineCryptoConfig({ keyProvider: 'aws-kms', /* ... */ }),
360+
crypto: defineCryptoConfig({ keyProvider: 'hashicorp-vault', /* ... */ }),
292361
```
293362

294-
An unregistered provider name is fail-closed at resolve time: the platform never
295-
falls back to a weaker or shared key.
363+
A custom provider declares `contractVersion = CRYPTO_CONTRACT_VERSION` so
364+
`KeyProviderRegistry.register()` can reject a provider built against an incompatible
365+
crypto contract (a newer contract throws; an older/absent one warns once), exactly as
366+
the AI and billing satellites gate their extensions. An unregistered provider name is
367+
fail-closed at resolve time: the platform never falls back to a weaker or shared key.
368+
369+
<Callout type="info" title="Encrypting large blobs">
370+
For blobs too large for a single GCM tag (vault's job), crypto exposes a framed
371+
enc_v2 stream envelope (`sealFramedV2` / `sealFramedV2Stream`): the payload is split
372+
into fixed-size frames, each an enc_v2 seal under the same DEK with the frame counter
373+
bound into the authenticated header, so a reordered / dropped / truncated frame fails
374+
closed. It is composition of the one core cipher, not a second construction.
375+
</Callout>
296376

297377
## Rowscope placement
298378

@@ -302,6 +382,16 @@ wrapped under a per-tenant KEK, reading another tenant's `wrapped_dek` bytes is
302382
useless without that tenant's KEK, so crypto **can** live in a shared table (unlike
303383
the AI vector store, whose embeddings are invertible and which refuses rowscope).
304384

385+
<Callout type="warning" title="Only under rowscope-pg, and mind the RLS flag">
386+
Run the `create_crypto_wrapped_deks_rowscope` stub ONLY under the `rowscope-pg` driver
387+
(schema-pg / database-pg get the per-tenant table from `tenant:migrate` instead). The
388+
RLS block is conditional on `isolation.rowScopeRls: true`: the store only sets the
389+
per-transaction GUC when the driver reports RLS is on, so if you keep the FORCE RLS
390+
policy but leave `rowScopeRls: false`, the policy matches no rows and every crypto
391+
read/write fails closed. Either set `rowScopeRls: true`, or drop the ENABLE/FORCE/policy
392+
block from the stub and rely on the store's always-on `tenant_id` predicate.
393+
</Callout>
394+
305395
`node ace configure` publishes the central
306396
`create_crypto_wrapped_deks_rowscope` migration stub. It creates the shared table
307397
with a `tenant_id` scope column, a per-`(tenant_id, subject_id, category)` partial

packages/crypto/.c8rc.json

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,6 @@
1212
"src/sdk/**",
1313
"src/commands/**",
1414
"src/internal/operation_lock.ts",
15-
"src/services/worm_shred_ledger.ts",
1615
"build/**",
1716
"bin/**",
1817
"tests/**"
@@ -21,8 +20,8 @@
2120
"report-dir": "./coverage",
2221
"clean": true,
2322
"check-coverage": true,
24-
"statements": 84,
25-
"branches": 80,
26-
"functions": 84,
27-
"lines": 84
23+
"statements": 91,
24+
"branches": 85,
25+
"functions": 90,
26+
"lines": 91
2827
}

packages/crypto/CHANGELOG.md

Lines changed: 20 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,21 @@ Added:
2020
a per-tenant `crypto_wrapped_deks` table (no plaintext DEK at rest, I2); a field is
2121
sealed under its DEK with the kernel's authenticated `enc_v2` envelope. The
2222
built-in `EnvKeyProvider` derives the KEK from `APP_KEY`; a host binds AWS KMS,
23-
HashiCorp Vault or a custom provider on the `KeyProviderRegistry`.
23+
HashiCorp Vault or a custom provider on the `KeyProviderRegistry`. Every provider
24+
declares a `contractVersion`, checked at registration (`assertContractCompat`) so an
25+
incompatible provider is rejected fail-closed, matching the AI/billing extension gate.
26+
- **HTTP-backed KeyProviders are SSRF-pinned by construction.** An `HttpKeyProvider`
27+
base routes every outbound through core `safeFetch` (DNS/IP pin, no redirects), so a
28+
mis-set KMS address can never reach loopback / RFC-1918 / cloud-metadata (T13);
29+
`check-crypto-invariant-11` forbids any second, unpinned egress. A reference
30+
`VaultKeyProvider` (HashiCorp Vault transit engine) ships on that base, with a gated
31+
real-Vault smoke test.
32+
- **Framed enc_v2 stream envelope (§6.8).** `sealFramedV2` / `openFramedV2` (and their
33+
streaming forms) seal a large blob as fixed-size frames, each an enc_v2 seal under the
34+
same DEK with the frame counter bound into the authenticated header and a counted
35+
terminator frame, so a reordered / dropped / duplicated / truncated frame fails
36+
closed. It is composition of the one core cipher (vault consumes it), never a second
37+
construction (I1).
2438
- **Placement follows the isolation driver (I1).** `PgWrappedDekStore` asks the
2539
active driver `tableLocation(tenant)` and never hardcodes a schema, so it is
2640
correct on `schema-pg`, `database-pg` and `connection`. Under `rowscope-pg` the
@@ -50,6 +64,8 @@ Added:
5064
- **Isthmus guard registry.** Every fail-closed refusal emits the kernel's public
5165
`IsthmusGuardTripped` event with a `guard.crypto_*` id, counted per tenant on the
5266
`crypto_guard_rejections` metric.
53-
- **Structural guards** (`check-crypto-invariant-{1,2,3,4,5,8,9,10}`) pinning the
54-
invariants at review time, plus a real-Postgres integration suite across every
55-
placement.
67+
- **Structural guards** (`check-crypto-invariant-{1..11}`) pinning the invariants and
68+
the KeyProvider SSRF discipline at review time, plus a real-Postgres integration
69+
suite across every placement (KMS-down fail-closed, crash-between-PENDING-and-COMMITTED
70+
reconciliation, KEK rotation, governance-absent refusal, and the framed-envelope
71+
integrity matrix).

packages/crypto/package.json

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -45,9 +45,9 @@
4545
"migrations": "stubs/migrations",
4646
"perTenantMigrations": "build/tenant_migrations",
4747
"minMergedCoverage": {
48-
"lines": 60,
49-
"functions": 60,
50-
"branches": 60
48+
"lines": 80,
49+
"functions": 80,
50+
"branches": 75
5151
},
5252
"provider": "@adonisjs-lasagna/crypto/provider",
5353
"commands": "@adonisjs-lasagna/crypto/commands",

packages/crypto/providers/crypto_provider.ts

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import {
55
type SatelliteProviderContract,
66
} from '@adonisjs-lasagna/saas-tenancy/sdk'
77
import { getActiveDriver, MetricsService } from '@adonisjs-lasagna/saas-tenancy/services'
8+
import { getConfig } from '@adonisjs-lasagna/saas-tenancy/config'
89
import { tenancy } from '@adonisjs-lasagna/saas-tenancy'
910
import { setCryptoGuardMetricSink } from '../src/isthmus/crypto_guard_audit.js'
1011
import WormLedgerWriter, { type WormDb } from '@adonisjs-lasagna/saas-tenancy/worm-ledger'
@@ -62,13 +63,16 @@ export default class CryptoProvider implements SatelliteProviderContract {
6263
activeScopeTenantId,
6364
})
6465
// The two-phase shred audit: the shared core WORM ledger (per-tenant hash
65-
// chain in backoffice, append-only), wrapped as a ShredLedger. The
66+
// chain in the backoffice schema, append-only), wrapped as a ShredLedger. The
6667
// erasability gate is wired from governance's config seam (absent ⇒ shred is
67-
// fail-closed refused, I7). encrypt/decrypt do not depend on either.
68+
// fail-closed refused, I7). encrypt/decrypt do not depend on either. The
69+
// connection name is resolved from config (never a hardcoded literal): the
70+
// ledger's SQL is schema-qualified `backoffice.worm_ledger` but runs on the
71+
// host's central connection, exactly as the real integration harness wires it.
6872
const ledger = new WormShredLedger(
6973
new WormLedgerWriter({
7074
getDb: async () => (await makeDb()) as unknown as WormDb,
71-
connectionName: 'backoffice',
75+
connectionName: getConfig().centralConnectionName,
7276
activeScopeTenantId,
7377
})
7478
)

packages/crypto/src/commands/tenant_crypto_rekek.ts

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -64,8 +64,10 @@ export default class TenantCryptoRekek extends BaseCommand {
6464
let tenant: TenantModelContract
6565
try {
6666
tenant = await repo.findByIdOrFail(this.tenant)
67-
} catch (error: any) {
68-
this.logger.error(`Tenant not found: ${error.message}`)
67+
} catch (error: unknown) {
68+
this.logger.error(
69+
`Tenant not found: ${error instanceof Error ? error.message : String(error)}`
70+
)
6971
this.exitCode = 1
7072
return
7173
}
@@ -92,20 +94,26 @@ export default class TenantCryptoRekek extends BaseCommand {
9294
scanned: acc.scanned + summary.scanned,
9395
current: acc.current + summary.current,
9496
rotated: acc.rotated + summary.rotated,
97+
shreddedDuringRewrap: acc.shreddedDuringRewrap + summary.shreddedDuringRewrap,
9598
failed: acc.failed + summary.failed,
9699
}),
97-
{ scanned: 0, current: 0, rotated: 0, failed: 0 }
100+
{ scanned: 0, current: 0, rotated: 0, shreddedDuringRewrap: 0, failed: 0 }
98101
)
99102

100103
if (this.json) {
101104
this.logger.log(JSON.stringify({ dryRun: this.dryRun, tenants: results, totals }, null, 2))
102105
} else {
103106
for (const { tenantId, summary } of results) {
104107
if (summary.scanned === 0) continue // quietly skip tenants with no DEKs
108+
// A row shredded mid-rotation is a benign race; surface it only when it happened.
109+
const shredNote =
110+
summary.shreddedDuringRewrap > 0
111+
? `, shredded mid-rotate ${summary.shreddedDuringRewrap}`
112+
: ''
105113
const line =
106114
`${tenantId}: scanned ${summary.scanned}, ` +
107115
`${this.dryRun ? 'would re-wrap' : 're-wrapped'} ${summary.rotated}, ` +
108-
`already current ${summary.current}, failed ${summary.failed}`
116+
`already current ${summary.current}${shredNote}, failed ${summary.failed}`
109117
this.logger.log(summary.failed > 0 ? this.colors.red(line) : this.colors.dim(line))
110118
for (const f of summary.failures) {
111119
this.logger.log(

packages/crypto/src/commands/tenant_crypto_shred.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -73,8 +73,10 @@ export default class TenantCryptoShred extends BaseCommand {
7373
let tenant: TenantModelContract
7474
try {
7575
tenant = await repo.findByIdOrFail(this.tenant)
76-
} catch (error: any) {
77-
this.logger.error(`Tenant not found: ${error.message}`)
76+
} catch (error: unknown) {
77+
this.logger.error(
78+
`Tenant not found: ${error instanceof Error ? error.message : String(error)}`
79+
)
7880
this.exitCode = 1
7981
return
8082
}

packages/crypto/src/exceptions/crypto_exception.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,13 +4,17 @@ export const CRYPTO_ERROR_CODES = [
44
'dek_invalid', // an unwrapped DEK is not 32 bytes (a corrupt / wrong-provider wrap)
55
'dek_conflict', // two live DEKs for one (subject, category) were attempted (partial UNIQUE, I10, T12)
66
'keyprovider_missing', // no KeyProvider is registered for the configured name
7+
'keyprovider_unavailable', // an HTTP-backed KeyProvider (KMS/Vault) backend is unreachable, blocked by the SSRF pin, or errored
78
'index_key_unavailable', // the KeyProvider yields no blind-index key: fail closed, never a bare unkeyed hash (I5, T3)
89
'no_tenant_scope', // EncryptedRepository was called with no active tenant scope: fail closed, never a cross-tenant DEK
910
'tenant_scope_mismatch', // a raw-SQL query's tenant differs from the active tenancy scope (ContextSeal)
1011
'config_invalid', // a malformed `config.crypto` block
1112
'shred_refused', // governance absent, or the category is not erasable (legal hold): I7 fail-closed
1213
'shred_unaudited', // no WORM ledger, or the PENDING append failed before the delete: abort, nothing destroyed
1314
'shred_audit_unfinalized', // the COMMITTED mark failed after the delete: erasure done, a PENDING row remains (reported)
15+
'shred_in_progress', // another shred/provision holds the serialize lock and it could not be acquired in the wait window (retriable)
16+
'insert_failed', // a wrapped-DEK INSERT ... RETURNING produced no row (fail-closed rather than crashing on undefined)
17+
'framed_stream_invalid', // a framed enc_v2 stream envelope failed integrity: reorder / drop / duplicate / truncation / cross-stream frame (§6.8)
1418
] as const
1519

1620
export type CryptoErrorCode = (typeof CRYPTO_ERROR_CODES)[number]

packages/crypto/src/index.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,21 @@ export type { CategoryKey, KeyProvider, SubjectId, WrappedDek } from './types/ke
2828
// Blind-index (deterministic search HMAC) options (crypto §6.5, I5).
2929
export type { BlindIndexOptions } from './internal/blind_index.js'
3030

31+
// Framed enc_v2 stream envelope (crypto §6.8): the composition vault consumes to seal
32+
// large blobs before upload. One AEAD (core's), applied per frame with reorder/
33+
// truncation binding — NOT a new cipher (I1).
34+
export {
35+
sealFramedV2,
36+
openFramedV2,
37+
sealFramedV2Stream,
38+
openFramedV2Stream,
39+
} from './internal/framed_stream.js'
40+
export {
41+
DEFAULT_FRAME_SIZE,
42+
FRAMED_STREAM_PREFIX,
43+
type FramedSealOptions,
44+
} from './types/framed_envelope.js'
45+
3146
// Transparent field-encryption decorators (crypto §6.4 Option A).
3247
export { encrypted, searchable } from './models/encrypted_columns.js'
3348
export type {
@@ -60,6 +75,13 @@ export { default as EncryptedRepository } from './services/encrypted_repository.
6075
export type { EncryptedRepositoryDeps } from './services/encrypted_repository.js'
6176
export { default as KeyProviderRegistry } from './services/key_provider_registry.js'
6277
export { default as EnvKeyProvider } from './services/env_key_provider.js'
78+
// The SSRF-pinned base for HTTP-backed KeyProviders (KMS/Vault) + a Vault reference
79+
// (crypto §6.2, §8; T13). A host binds one of these (or a custom subclass) and names it
80+
// in `config.crypto.keyProvider`. Every outbound is routed through core safeFetch.
81+
export { default as HttpKeyProvider } from './services/http_key_provider.js'
82+
export type { HttpKeyProviderOptions, HttpKeyRequest } from './services/http_key_provider.js'
83+
export { default as VaultKeyProvider } from './services/vault_key_provider.js'
84+
export type { VaultKeyProviderOptions } from './services/vault_key_provider.js'
6385
export { default as WormShredLedger } from './services/worm_shred_ledger.js'
6486
export { default as PgWrappedDekStore } from './services/pg_wrapped_dek_store.js'
6587
export type {

0 commit comments

Comments
 (0)