@centient/secrets 0.10.0 → 0.11.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/README.md +355 -7
- package/dist/audit/hmac-chain.d.ts +117 -0
- package/dist/audit/hmac-chain.d.ts.map +1 -0
- package/dist/audit/hmac-chain.js +811 -0
- package/dist/audit/hmac-chain.js.map +1 -0
- package/dist/audit/ocsf.d.ts +108 -0
- package/dist/audit/ocsf.d.ts.map +1 -0
- package/dist/audit/ocsf.js +204 -0
- package/dist/audit/ocsf.js.map +1 -0
- package/dist/cli/hidden-prompt.d.ts.map +1 -1
- package/dist/cli/hidden-prompt.js +55 -10
- package/dist/cli/hidden-prompt.js.map +1 -1
- package/dist/cli/messages.d.ts +40 -8
- package/dist/cli/messages.d.ts.map +1 -1
- package/dist/cli/messages.js +34 -8
- package/dist/cli/messages.js.map +1 -1
- package/dist/client.d.ts +26 -0
- package/dist/client.d.ts.map +1 -0
- package/dist/client.js +129 -0
- package/dist/client.js.map +1 -0
- package/dist/compatibility.d.ts +199 -0
- package/dist/compatibility.d.ts.map +1 -0
- package/dist/compatibility.js +419 -0
- package/dist/compatibility.js.map +1 -0
- package/dist/index.d.ts +19 -5
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +16 -2
- package/dist/index.js.map +1 -1
- package/dist/policies/audit-trail.d.ts +25 -0
- package/dist/policies/audit-trail.d.ts.map +1 -0
- package/dist/policies/audit-trail.js +31 -0
- package/dist/policies/audit-trail.js.map +1 -0
- package/dist/sinks/index.d.ts +9 -0
- package/dist/sinks/index.d.ts.map +1 -0
- package/dist/sinks/index.js +5 -0
- package/dist/sinks/index.js.map +1 -0
- package/dist/sinks/jsonl.d.ts +16 -0
- package/dist/sinks/jsonl.d.ts.map +1 -0
- package/dist/sinks/jsonl.js +51 -0
- package/dist/sinks/jsonl.js.map +1 -0
- package/dist/sinks/otel.d.ts +22 -0
- package/dist/sinks/otel.d.ts.map +1 -0
- package/dist/sinks/otel.js +155 -0
- package/dist/sinks/otel.js.map +1 -0
- package/dist/sinks/queue.d.ts +12 -0
- package/dist/sinks/queue.d.ts.map +1 -0
- package/dist/sinks/queue.js +89 -0
- package/dist/sinks/queue.js.map +1 -0
- package/dist/sinks/syslog.d.ts +17 -0
- package/dist/sinks/syslog.d.ts.map +1 -0
- package/dist/sinks/syslog.js +103 -0
- package/dist/sinks/syslog.js.map +1 -0
- package/dist/sinks/types.d.ts +27 -0
- package/dist/sinks/types.d.ts.map +1 -0
- package/dist/sinks/types.js +18 -0
- package/dist/sinks/types.js.map +1 -0
- package/dist/vault/policy.d.ts +64 -34
- package/dist/vault/policy.d.ts.map +1 -1
- package/dist/vault/policy.js +109 -67
- package/dist/vault/policy.js.map +1 -1
- package/dist/vault/session-vault.d.ts +43 -0
- package/dist/vault/session-vault.d.ts.map +1 -1
- package/dist/vault/session-vault.js +323 -36
- package/dist/vault/session-vault.js.map +1 -1
- package/dist/vault/sidecar.d.ts +9 -0
- package/dist/vault/sidecar.d.ts.map +1 -1
- package/dist/vault/sidecar.js +72 -3
- package/dist/vault/sidecar.js.map +1 -1
- package/dist/vault/types.d.ts +16 -9
- package/dist/vault/types.d.ts.map +1 -1
- package/dist/vault/vault-env.d.ts +41 -62
- package/dist/vault/vault-env.d.ts.map +1 -1
- package/dist/vault/vault-env.js +139 -75
- package/dist/vault/vault-env.js.map +1 -1
- package/dist/vault/vault-gpg.d.ts +1 -0
- package/dist/vault/vault-gpg.d.ts.map +1 -1
- package/dist/vault/vault-gpg.js +1 -0
- package/dist/vault/vault-gpg.js.map +1 -1
- package/dist/vault/vault-libsecret.d.ts +1 -0
- package/dist/vault/vault-libsecret.d.ts.map +1 -1
- package/dist/vault/vault-libsecret.js +7 -6
- package/dist/vault/vault-libsecret.js.map +1 -1
- package/dist/vault/vault-onepassword.d.ts +1 -0
- package/dist/vault/vault-onepassword.d.ts.map +1 -1
- package/dist/vault/vault-onepassword.js +1 -0
- package/dist/vault/vault-onepassword.js.map +1 -1
- package/dist/vault/vault-utils.d.ts +7 -6
- package/dist/vault/vault-utils.d.ts.map +1 -1
- package/dist/vault/vault-utils.js +8 -7
- package/dist/vault/vault-utils.js.map +1 -1
- package/dist/vault/vault-windows.d.ts +2 -1
- package/dist/vault/vault-windows.d.ts.map +1 -1
- package/dist/vault/vault-windows.js +12 -8
- package/dist/vault/vault-windows.js.map +1 -1
- package/dist/vault/vault.d.ts +6 -7
- package/dist/vault/vault.d.ts.map +1 -1
- package/dist/vault/vault.js +37 -193
- package/dist/vault/vault.js.map +1 -1
- package/package.json +8 -2
package/README.md
CHANGED
|
@@ -6,6 +6,11 @@ Cross-platform secrets vault with AES-256-GCM encryption and platform-native key
|
|
|
6
6
|
|
|
7
7
|
> **Daemons / long-running processes:** see [Session-backed vault (`openVault`)](./docs/session-vault.md) for the recommended API — single master-key unlock per session, in-memory cached reads, mtime-check coherence with the CLI, rollback protection via monotonic version + sidecar.
|
|
8
8
|
|
|
9
|
+
> **Creating a session vault:** use `createVault({ path, key })`. It composes the
|
|
10
|
+
> initial AAD-bound ciphertext and rollback sidecar without exposing their
|
|
11
|
+
> private wire format. It is non-destructive, never manages provider state, and
|
|
12
|
+
> refuses incomplete vault/sidecar pairs. See the [first-use guide](./docs/session-vault.md#first-use-creation).
|
|
13
|
+
|
|
9
14
|
## Installation
|
|
10
15
|
|
|
11
16
|
```bash
|
|
@@ -24,22 +29,187 @@ pnpm add @centient/secrets
|
|
|
24
29
|
- Platform-native key storage (macOS Keychain, Linux secret-service)
|
|
25
30
|
- Pluggable key providers (Keychain, 1Password, passphrase)
|
|
26
31
|
- Credential vault with session management, plus an opt-in 1Password credential backend
|
|
32
|
+
- Instance-scoped `SecretsClient` factories with composable policy middleware
|
|
27
33
|
- Environment detection (CI, Docker, SSH, headless, agent)
|
|
28
34
|
- Built-in CLI for interactive secret management
|
|
29
35
|
|
|
30
36
|
## Quick Start
|
|
31
37
|
|
|
38
|
+
The client factory is the primary API when a consumer needs an explicit storage
|
|
39
|
+
provider, caller identity, or policies. Each client snapshots its policy-stack
|
|
40
|
+
membership, so two consumers in one process cannot accidentally add or remove
|
|
41
|
+
one another's enforcement. Policy instances may retain their own internal state.
|
|
42
|
+
|
|
32
43
|
```typescript
|
|
33
|
-
import {
|
|
44
|
+
import {
|
|
45
|
+
KeychainVault,
|
|
46
|
+
auditTrail,
|
|
47
|
+
createSecretsClient,
|
|
48
|
+
type CredentialAuditEvent,
|
|
49
|
+
} from "@centient/secrets";
|
|
50
|
+
|
|
51
|
+
const auditBuffer: CredentialAuditEvent[] = [];
|
|
52
|
+
const client = createSecretsClient({
|
|
53
|
+
provider: new KeychainVault(),
|
|
54
|
+
policies: [
|
|
55
|
+
auditTrail({
|
|
56
|
+
sink: (event) => auditBuffer.push(event),
|
|
57
|
+
includeReads: true,
|
|
58
|
+
}),
|
|
59
|
+
],
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
await client.storeCredential("my-service.api-key", "<your-api-key>", {
|
|
63
|
+
caller: { id: "my-service", kind: "service" },
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
const value = await client.getCredential("my-service.api-key", {
|
|
67
|
+
caller: { id: "my-service", kind: "service" },
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
await client.deleteCredential("my-service.api-key", {
|
|
71
|
+
caller: { id: "my-service", kind: "service" },
|
|
72
|
+
});
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
`SecretsProvider` is the credential-*value* storage SPI used by the factory.
|
|
76
|
+
The older `VaultBackend` name remains as a deprecated type alias, so existing
|
|
77
|
+
implementations continue to compile. This is separate from `KeyProvider`, which
|
|
78
|
+
locates the master key used to open an encrypted session vault.
|
|
79
|
+
|
|
80
|
+
Policies use onion ordering: `before` runs top-to-bottom and `after` runs
|
|
81
|
+
bottom-to-top. A throwing `before` fails closed without contacting the provider;
|
|
82
|
+
the rejecting policy and every previously entered policy still receive the
|
|
83
|
+
rejection event while later policies do not run. Operation metadata includes the
|
|
84
|
+
operation, key or prefix, and optional caller identity, but never the credential
|
|
85
|
+
value. Because `auditTrail()` promises to observe policy denials, place it before
|
|
86
|
+
every policy that has a `before` hook. Client construction and legacy policy
|
|
87
|
+
replacement reject an unsafe order instead of silently leaving denials unaudited.
|
|
88
|
+
|
|
89
|
+
### Audit telemetry: OpenTelemetry + OCSF
|
|
90
|
+
|
|
91
|
+
Installing an `auditTrail()` policy maps every value-free credential outcome —
|
|
92
|
+
including provider failures and policy denials — to an OCSF 1.9.0 Entity
|
|
93
|
+
Management record. `auditTrail()` defaults to an OpenTelemetry sink; audit is
|
|
94
|
+
not globally enabled merely by importing the package.
|
|
95
|
+
|
|
96
|
+
```typescript
|
|
97
|
+
import { auditTrail, createSecretsClient } from "@centient/secrets";
|
|
98
|
+
|
|
99
|
+
const client = createSecretsClient({
|
|
100
|
+
provider,
|
|
101
|
+
policies: [auditTrail()],
|
|
102
|
+
});
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
`@centient/secrets` depends on `@opentelemetry/api`, not an SDK or exporter.
|
|
106
|
+
The application owns tracer-provider, sampler, exporter, collector, and shutdown
|
|
107
|
+
configuration. The sink reports a non-recording or unsampled span as degraded;
|
|
108
|
+
for an audit pipeline, configure an always-on audit sampler and force-flush the
|
|
109
|
+
application's SDK before shutdown.
|
|
110
|
+
|
|
111
|
+
The released OCSF schema corrects an older ADR-002 label: `3` is the Identity &
|
|
112
|
+
Access Management category and `3004` is the **Entity Management class**; there
|
|
113
|
+
is no released class named Credential Activity. Stores are upserts and
|
|
114
|
+
enumeration has no 3004 activity, so both use `activity_id: 99` (`Store` or
|
|
115
|
+
`Enumerate`) instead of claiming a create/update distinction the provider SPI
|
|
116
|
+
cannot observe. Policy denial is `action_id: 2` with `status_id: 2`.
|
|
117
|
+
|
|
118
|
+
Sink migration: the pre-1.0 audit seam passed its internal `SecretsEvent`
|
|
119
|
+
directly to a callback. A sink now receives `CredentialAuditEvent`. Read the
|
|
120
|
+
credential key from `entity.name`, provider from `entity.data.vault_name`, raw
|
|
121
|
+
event classification from `unmapped["centient.secrets.event_type"]`, and latency
|
|
122
|
+
from `unmapped["centient.secrets.operation.duration_ms"]`. Credential values and
|
|
123
|
+
provider/policy exception text are not copied into the OCSF record.
|
|
124
|
+
|
|
125
|
+
Non-OTel deployments can use the JSONL or RFC 5424 syslog sinks. Both enqueue in
|
|
126
|
+
emission order, bound their queues, report background failures, and expose
|
|
127
|
+
`flush()`/`close()` so a caller can observe delivery failure:
|
|
128
|
+
|
|
129
|
+
```typescript
|
|
130
|
+
import { auditTrail, createSecretsClient } from "@centient/secrets";
|
|
131
|
+
import { jsonlAuditSink } from "@centient/secrets/sinks";
|
|
132
|
+
|
|
133
|
+
const sink = jsonlAuditSink({ filePath: "/var/log/my-service/secrets.jsonl" });
|
|
134
|
+
const client = createSecretsClient({ provider, policies: [auditTrail({ sink })] });
|
|
135
|
+
|
|
136
|
+
// ...credential operations...
|
|
137
|
+
await sink.flush?.();
|
|
138
|
+
```
|
|
34
139
|
|
|
35
|
-
|
|
36
|
-
|
|
140
|
+
JSONL contains bare OCSF records. Syslog serializes the same record as the RFC
|
|
141
|
+
5424 message body. New JSONL files/directories default to owner-only modes
|
|
142
|
+
(`0o600`/`0o700`); existing permissions are not silently changed. UDP syslog's
|
|
143
|
+
`flush()` confirms local socket acceptance, not remote persistence, and UDP does
|
|
144
|
+
not provide transport encryption or authentication—use a local syslog daemon or
|
|
145
|
+
inject a protected sender for a remote hop. Neither sink routes through
|
|
146
|
+
`@centient/logger`'s
|
|
147
|
+
`AuditWriter` or the SDK's remote `AuditResource`: both existing seams have a
|
|
148
|
+
different closed event schema and async/failure contract. Keeping the
|
|
149
|
+
`AuditSink` boundary structural prevents either package from silently reshaping
|
|
150
|
+
or truncating a signed record.
|
|
151
|
+
|
|
152
|
+
#### HMAC record-integrity chains
|
|
153
|
+
|
|
154
|
+
Create an optional process-local chain with a dedicated 32-byte audit key. The
|
|
155
|
+
consumer must load that key from its deployment trust store and inject it; the
|
|
156
|
+
library never reads a file/environment/provider implicitly, silently generates
|
|
157
|
+
a key, or reuses/derives from the vault-encryption key managed by `KeyProvider`.
|
|
37
158
|
|
|
38
|
-
|
|
39
|
-
|
|
159
|
+
```typescript
|
|
160
|
+
import {
|
|
161
|
+
auditTrail,
|
|
162
|
+
createHmacAuditChain,
|
|
163
|
+
createSecretsClient,
|
|
164
|
+
verifyHmacAuditChain,
|
|
165
|
+
} from "@centient/secrets";
|
|
166
|
+
import { jsonlAuditSink } from "@centient/secrets/sinks";
|
|
167
|
+
|
|
168
|
+
const chain = createHmacAuditChain({
|
|
169
|
+
chainId: "my-service-secrets",
|
|
170
|
+
keyId: "audit-key-2026-08",
|
|
171
|
+
key: auditKeyBytes, // exactly 32 bytes, loaded by the application
|
|
172
|
+
});
|
|
173
|
+
const sink = jsonlAuditSink({ filePath: "/var/log/my-service/secrets.jsonl" });
|
|
174
|
+
const client = createSecretsClient({
|
|
175
|
+
provider,
|
|
176
|
+
policies: [auditTrail({ sink, chain })],
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
// Rotate only at an explicit emission boundary; IDs and key material may never be reused.
|
|
180
|
+
chain.rotate({ keyId: "audit-key-2026-09", key: nextAuditKeyBytes });
|
|
181
|
+
|
|
182
|
+
await sink.flush?.();
|
|
183
|
+
const anchor = chain.checkpoint();
|
|
184
|
+
// Persist `anchor` outside the audit log's trust domain.
|
|
185
|
+
```
|
|
40
186
|
|
|
41
|
-
|
|
42
|
-
|
|
187
|
+
Each chained event uses OCSF 1.9's Record Integrity profile:
|
|
188
|
+
`metadata.sequence`, a chain UID, the current HMAC fingerprint, and the previous
|
|
189
|
+
event reference. `verifyHmacAuditChain()` accepts a trusted key resolver; bind
|
|
190
|
+
verification to the deployment's trusted `expectedChainId` so another valid
|
|
191
|
+
chain cannot be substituted. The verifier
|
|
192
|
+
returns a discriminated result rather than throwing on hostile records. It
|
|
193
|
+
detects content mutation (including the final event), insertion, duplication,
|
|
194
|
+
internal deletion, reorder, unknown/wrong keys, and invalid rotations. Supply a
|
|
195
|
+
trusted initial checkpoint when verifying a resumed segment and an expected
|
|
196
|
+
final checkpoint to detect prefix/tail deletion; HMAC alone cannot prove that
|
|
197
|
+
an attacker did not delete the whole log. Checkpoints carry chain-scoped,
|
|
198
|
+
HMAC-derived key fingerprints (never key bytes), so historical key material
|
|
199
|
+
cannot be reintroduced under a fresh ID after restart. A global multi-process sequence needs
|
|
200
|
+
an external serialized coordinator—each independent process should otherwise
|
|
201
|
+
use its own `chainId`.
|
|
202
|
+
|
|
203
|
+
The module-level functions remain the simple, backwards-compatible path. They
|
|
204
|
+
delegate through the process's default client and the legacy global policy
|
|
205
|
+
configuration:
|
|
206
|
+
|
|
207
|
+
```typescript
|
|
208
|
+
import { storeCredential, getCredential, deleteCredential } from "@centient/secrets";
|
|
209
|
+
|
|
210
|
+
await storeCredential("my-service.api-key", "<your-api-key>");
|
|
211
|
+
const value = await getCredential("my-service.api-key");
|
|
212
|
+
await deleteCredential("my-service.api-key");
|
|
43
213
|
```
|
|
44
214
|
|
|
45
215
|
### Encryption Utilities
|
|
@@ -62,6 +232,34 @@ if (isCIEnvironment()) {
|
|
|
62
232
|
}
|
|
63
233
|
```
|
|
64
234
|
|
|
235
|
+
### Auth CLI messages
|
|
236
|
+
|
|
237
|
+
`AUTH_MESSAGES` is the shared, i18n-ready catalog of user-visible strings for
|
|
238
|
+
the credential lifecycle a CLI drives on top of this vault — `login`, `logout`,
|
|
239
|
+
`auth status`, `auth refresh`, device flow, and api-key entry. Import it; do not
|
|
240
|
+
copy it. It is public precisely so a consuming CLI stops maintaining a fork that
|
|
241
|
+
drifts from this one.
|
|
242
|
+
|
|
243
|
+
```typescript
|
|
244
|
+
import { AUTH_MESSAGES, type AuthMessages } from "@centient/secrets";
|
|
245
|
+
|
|
246
|
+
process.stderr.write(AUTH_MESSAGES.error.vaultWriteFailed + "\n");
|
|
247
|
+
process.stderr.write(AUTH_MESSAGES.info.loginPrompt(verificationUri) + "\n");
|
|
248
|
+
process.stderr.write(AUTH_MESSAGES.warning.tokenExpiringSoon(5) + "\n");
|
|
249
|
+
```
|
|
250
|
+
|
|
251
|
+
No value interpolates at the call site: fixed messages are string constants and
|
|
252
|
+
parameterized ones are template functions returning the finished string, so a
|
|
253
|
+
translation layer can replace the catalog wholesale. `AuthMessages` is the
|
|
254
|
+
catalog's type, for typing such a table.
|
|
255
|
+
|
|
256
|
+
**Scope.** The catalog covers the auth lifecycle above, plus the one
|
|
257
|
+
credential-storage warning this package itself emits
|
|
258
|
+
(`warning.envVaultNoStorage`, from the read-only `EnvVault` fallback). It does
|
|
259
|
+
**not** cover the `centient secrets …` operator CLI (`runSecrets`) or the
|
|
260
|
+
`[secrets] …` vault diagnostics — those are command-specific and carry their own
|
|
261
|
+
next-step lines, so they stay at their call sites by design.
|
|
262
|
+
|
|
65
263
|
## Key Providers
|
|
66
264
|
|
|
67
265
|
| Provider | Platform | Description |
|
|
@@ -135,6 +333,39 @@ read into a different item and field, so the write would be silently unreadable.
|
|
|
135
333
|
Refusing is the better failure; a caller believing a credential is saved when it
|
|
136
334
|
cannot be read back is worse than a caller told no.
|
|
137
335
|
|
|
336
|
+
### Environment fallback key mapping
|
|
337
|
+
|
|
338
|
+
`EnvVault` is the read-only terminal backend for hosts with no secure credential
|
|
339
|
+
store. Every valid logical credential key has exactly one environment-variable
|
|
340
|
+
name, so a miss means that variable is genuinely unset rather than that the
|
|
341
|
+
backend does not know how to address the key. The historical mapping is retained:
|
|
342
|
+
|
|
343
|
+
| Credential key | Environment variable |
|
|
344
|
+
|---|---|
|
|
345
|
+
| `auth-token` | `ENGRAM_API_KEY` |
|
|
346
|
+
| `sync-peers.dek` | `CENTIENT_SECRET_SYNC_2DPEERS_2EDEK` |
|
|
347
|
+
|
|
348
|
+
For non-auth keys, alphanumerics are uppercased, `-` is escaped as `_2D`, `.` as
|
|
349
|
+
`_2E`, and the result is prefixed with `CENTIENT_SECRET_`. `_` is forbidden by
|
|
350
|
+
the credential-key grammar, so the mapping stays reversible even for adjacent
|
|
351
|
+
separators. Use the exported helper instead of reimplementing it:
|
|
352
|
+
|
|
353
|
+
```ts
|
|
354
|
+
import { EnvVault, createSecretsClient, credentialKeyToEnvName } from "@centient/secrets";
|
|
355
|
+
|
|
356
|
+
credentialKeyToEnvName("sync-peers.dek");
|
|
357
|
+
// => "CENTIENT_SECRET_SYNC_2DPEERS_2EDEK"
|
|
358
|
+
|
|
359
|
+
const client = createSecretsClient({ provider: new EnvVault() });
|
|
360
|
+
```
|
|
361
|
+
|
|
362
|
+
An unset variable probes as `absent`. A defined-but-empty variable returns
|
|
363
|
+
`null` on retrieval and probes as `failed`, because zero bytes are not a usable
|
|
364
|
+
credential. `store()` remains read-only and returns `false`; `delete()` remains
|
|
365
|
+
an idempotent no-op. Environment values are unencrypted, inherited by child
|
|
366
|
+
processes, and may be readable through `/proc/<pid>/environ`; this fallback is
|
|
367
|
+
configuration plumbing, not secure storage.
|
|
368
|
+
|
|
138
369
|
### Per-consumer vault keys
|
|
139
370
|
|
|
140
371
|
By default `KeychainProvider` targets a single shared Keychain item
|
|
@@ -190,6 +421,13 @@ const vault = await openVault({ keyProvider: stub });
|
|
|
190
421
|
A custom provider can also wrap any backend (remote KMS, HSM, env-injected key)
|
|
191
422
|
as long as it implements the `KeyProvider` interface.
|
|
192
423
|
|
|
424
|
+
`KeyProvider` is intentionally separate from `SecretsProvider`, the
|
|
425
|
+
credential-value storage SPI. A key provider establishes or retrieves the one
|
|
426
|
+
master key used to unlock an encrypted session vault; a secrets provider stores
|
|
427
|
+
the individual credential values. The same technology may fill both roles—for
|
|
428
|
+
example, `OnePasswordProvider` and `OnePasswordVault`—but their instances,
|
|
429
|
+
configuration, key spaces, and operation lifecycles remain independent.
|
|
430
|
+
|
|
193
431
|
### Passphrase provider
|
|
194
432
|
|
|
195
433
|
For hosts without an OS keychain or 1Password CLI (e.g. a headless Linux box
|
|
@@ -221,6 +459,116 @@ Security tradeoffs vs OS-backed providers — choose deliberately:
|
|
|
221
459
|
of ms); daemons should call `openVault()` once at startup, before entering
|
|
222
460
|
their hot loop.
|
|
223
461
|
|
|
462
|
+
## Compatibility floor for consumers
|
|
463
|
+
|
|
464
|
+
Several releases of this package closed defects whose only symptom, at the old
|
|
465
|
+
version, is **silence**: a denied credential operation that leaves no audit
|
|
466
|
+
trace, a keychain write that reports success into a keychain the current
|
|
467
|
+
context's reader cannot see, a malformed key that stores on one backend and
|
|
468
|
+
reads as absent on the next. A consumer running such a version cannot learn
|
|
469
|
+
that from the package — the behaviour looks fine and the logs look clean.
|
|
470
|
+
|
|
471
|
+
So the provider publishes the list. `COMPATIBILITY_FLOOR` is a data table of
|
|
472
|
+
every known behavioural gap, keyed by the version that closed it, and
|
|
473
|
+
`assessCompatibility()` turns an installed version into a grade a `doctor`
|
|
474
|
+
command can print.
|
|
475
|
+
|
|
476
|
+
```ts
|
|
477
|
+
import { assessCompatibility, SECRETS_PACKAGE_VERSION } from "@centient/secrets";
|
|
478
|
+
|
|
479
|
+
const assessment = assessCompatibility(SECRETS_PACKAGE_VERSION);
|
|
480
|
+
|
|
481
|
+
if (assessment.grade !== "ok") {
|
|
482
|
+
console.warn(assessment.summary);
|
|
483
|
+
for (const gap of assessment.openGaps) {
|
|
484
|
+
console.warn(` [${gap.severity}] ${gap.symptom}`);
|
|
485
|
+
console.warn(` fixed in ${gap.closedIn} (${gap.reference})`);
|
|
486
|
+
if (gap.adoptionNote) console.warn(` on adoption: ${gap.adoptionNote}`);
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
```
|
|
490
|
+
|
|
491
|
+
`SECRETS_PACKAGE_VERSION` is the version of the build you actually imported, so
|
|
492
|
+
the assessment describes the code in your tree rather than whatever is latest on
|
|
493
|
+
the registry. It is kept in step with `package.json` by the release flow and
|
|
494
|
+
asserted by a test, so it cannot drift into a wrong answer.
|
|
495
|
+
|
|
496
|
+
### The grades
|
|
497
|
+
|
|
498
|
+
| `grade` | Meaning |
|
|
499
|
+
|---|---|
|
|
500
|
+
| `ok` | At or above every known fix. `openGaps` is empty. |
|
|
501
|
+
| `degraded` | Assessable, and one or more known gaps are open. `openGaps` lists them, oldest fix first; `highestSeverity` is the worst. |
|
|
502
|
+
| `unsupported` | Below `MIN_ASSESSABLE_VERSION` — older than the table describes. The gaps it *does* know about are still returned, but the list is not claimed to be complete. |
|
|
503
|
+
| `unknown` | The version could not be parsed. `reason` says why. |
|
|
504
|
+
|
|
505
|
+
### This is a graded floor, never a boot-time refusal
|
|
506
|
+
|
|
507
|
+
Nothing in this surface runs at import, nothing throws from a constructor, and
|
|
508
|
+
no vault path consults it. Every function is pure, total and advisory:
|
|
509
|
+
malformed input comes back as a typed `unknown` grade carrying the reason, not
|
|
510
|
+
as an exception. A package that refuses to load because its caller is old turns
|
|
511
|
+
a documentation problem into an outage — so grade, report, and let the consumer
|
|
512
|
+
decide.
|
|
513
|
+
|
|
514
|
+
### Auditing a manifest pin instead of an install
|
|
515
|
+
|
|
516
|
+
`assessCompatibility()` answers "what is open in the build I imported".
|
|
517
|
+
`assessPin()` answers "what could be open in any build this pin admits", by
|
|
518
|
+
grading the lowest version the range allows — which is also what a lockfile
|
|
519
|
+
that has never been refreshed is most likely holding:
|
|
520
|
+
|
|
521
|
+
```ts
|
|
522
|
+
assessPin("^0.6.0").openGaps.map((g) => g.id);
|
|
523
|
+
```
|
|
524
|
+
|
|
525
|
+
#### The range grammar `assessPin()` accepts
|
|
526
|
+
|
|
527
|
+
The whole range is matched against an anchored grammar, so an unsupported form
|
|
528
|
+
is rejected *as* unsupported rather than partially interpreted. Exactly four
|
|
529
|
+
forms resolve:
|
|
530
|
+
|
|
531
|
+
| Form | Example | Resolves to |
|
|
532
|
+
|---|---|---|
|
|
533
|
+
| caret | `^0.6.0` | `0.6.0` |
|
|
534
|
+
| tilde | `~0.9.1` | `0.9.1` |
|
|
535
|
+
| inclusive lower bound | `>=0.8.0` | `0.8.0` |
|
|
536
|
+
| exact | `0.10.0` | `0.10.0` |
|
|
537
|
+
|
|
538
|
+
The version component is a full `major.minor.patch`. A conventional leading `v`
|
|
539
|
+
(`v0.5.0`), horizontal whitespace after the operator (`>= 0.8.0`) and around the
|
|
540
|
+
whole range, and a `-prerelease` suffix are all accepted; the pre-release is
|
|
541
|
+
**kept**, because rounding `^1.0.0-rc.1` up to `1.0.0` would credit a consumer
|
|
542
|
+
for a release it is not running.
|
|
543
|
+
|
|
544
|
+
Everything else returns `grade: "unknown"` with a `reason` naming the accepted
|
|
545
|
+
grammar: composite ranges (`>=0.6.0 <0.8.0`), `||` unions, hyphen ranges
|
|
546
|
+
(`1.0.0 - 2.0.0`), wildcards (`*`), x-ranges (`1.x`, `1.2.x`), partial versions
|
|
547
|
+
(`1.2`), upper and exclusive bounds (`<0.9.0`, `>0.9.0`), protocol pins
|
|
548
|
+
(`workspace:*`, `npm:…`, `file:…`, a git URL), build metadata (`^1.2.3+build`,
|
|
549
|
+
which a parser *discards* rather than interprets), and any form carrying a
|
|
550
|
+
trailing token. A guess would grade the wrong version and hand back a clean bill
|
|
551
|
+
of health for a pin nobody actually checked.
|
|
552
|
+
|
|
553
|
+
#### Malformed input is graded, never thrown
|
|
554
|
+
|
|
555
|
+
`assessCompatibility()` and `assessPin()` narrow their argument through the same
|
|
556
|
+
guard before anything parses it, so a JS caller — or a `JSON.parse`d manifest,
|
|
557
|
+
or an `any` at a module boundary — that passes `null`, `undefined`, a number or
|
|
558
|
+
an object gets the documented `grade: "unknown"` result carrying a `reason`,
|
|
559
|
+
never an exception. `installedVersion` and `summary` are strings in that case
|
|
560
|
+
too, so the echo cannot hand back a value its declared type forbids.
|
|
561
|
+
|
|
562
|
+
### Adding an entry
|
|
563
|
+
|
|
564
|
+
A release earns a row when it closes a defect whose pre-fix symptom is silent.
|
|
565
|
+
A fix a consumer would notice on its own does not need one. Each entry carries
|
|
566
|
+
an `id` (stable — consumers may suppress by it), the `closedIn` version, a
|
|
567
|
+
`severity`, a one-line consumer-facing `symptom`, the `reference` issue, and an
|
|
568
|
+
`adoptionNote` whenever adopting the fix is not a pure no-op. The table is
|
|
569
|
+
ordered oldest fix first; `tests/compatibility.test.ts` pins the ordering, the
|
|
570
|
+
version parity, and the grade each known consumer pin earns.
|
|
571
|
+
|
|
224
572
|
## License
|
|
225
573
|
|
|
226
574
|
MIT
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import type { CredentialAuditEvent, CredentialAuditMetadata, CredentialAuditTypeUid, CredentialAuditUnmapped, OcsfAttestation } from "./ocsf.js";
|
|
2
|
+
export type AuditChainErrorCode = "INVALID_CHAIN_ID" | "INVALID_KEY_ID" | "INVALID_KEY" | "INVALID_CHECKPOINT" | "CHAIN_ID_MISMATCH" | "CHECKPOINT_KEY_MISMATCH" | "KEY_ID_REUSE" | "KEY_MATERIAL_REUSE" | "EVENT_ALREADY_CHAINED" | "SEQUENCE_EXHAUSTED" | "CHAIN_DESTROYED" | "CANONICALIZATION_FAILED";
|
|
3
|
+
/** Configuration/state errors produced by the writer side of the chain. */
|
|
4
|
+
export declare class AuditChainError extends Error {
|
|
5
|
+
readonly code: AuditChainErrorCode;
|
|
6
|
+
constructor(code: AuditChainErrorCode, message: string);
|
|
7
|
+
}
|
|
8
|
+
export interface AuditChainKey {
|
|
9
|
+
/** Stable, non-secret key version identifier. Never reuse an ID. */
|
|
10
|
+
readonly keyId: string;
|
|
11
|
+
/** Exactly 32 bytes. The chain copies the bytes and never owns this input. */
|
|
12
|
+
readonly key: Uint8Array;
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* Value-free state needed to resume or externally anchor a chain.
|
|
16
|
+
*
|
|
17
|
+
* A checkpoint is trustworthy only when the caller stores it outside the
|
|
18
|
+
* audit log's own trust domain. Supplying a checkpoint read from the same
|
|
19
|
+
* mutable log cannot prove that a prefix or suffix was not deleted.
|
|
20
|
+
*/
|
|
21
|
+
export interface AuditChainCheckpoint {
|
|
22
|
+
readonly chainId: string;
|
|
23
|
+
readonly sequenceNumber: number;
|
|
24
|
+
readonly eventUid: string;
|
|
25
|
+
readonly eventHash: string;
|
|
26
|
+
readonly typeUid: CredentialAuditTypeUid;
|
|
27
|
+
readonly keyId: string;
|
|
28
|
+
/** Ordered key IDs already used by this chain, including `keyId`. */
|
|
29
|
+
readonly usedKeyIds: readonly string[];
|
|
30
|
+
/**
|
|
31
|
+
* Chain-scoped, HMAC-derived key identifiers aligned with `usedKeyIds`.
|
|
32
|
+
* These are not key bytes; they let a trusted checkpoint prevent historical
|
|
33
|
+
* key material from being reintroduced under a fresh ID after restart.
|
|
34
|
+
*/
|
|
35
|
+
readonly usedKeyFingerprints: readonly string[];
|
|
36
|
+
}
|
|
37
|
+
export type ChainedCredentialAuditEvent = CredentialAuditEvent & {
|
|
38
|
+
readonly metadata: CredentialAuditMetadata & {
|
|
39
|
+
readonly sequence: number;
|
|
40
|
+
readonly profiles: readonly ["record_integrity"];
|
|
41
|
+
};
|
|
42
|
+
readonly unmapped: CredentialAuditUnmapped & {
|
|
43
|
+
readonly "centient.audit_chain.key_id": string;
|
|
44
|
+
};
|
|
45
|
+
readonly attestation_list: readonly [OcsfAttestation];
|
|
46
|
+
};
|
|
47
|
+
export interface CreateHmacAuditChainOptions extends AuditChainKey {
|
|
48
|
+
readonly chainId: string;
|
|
49
|
+
/** Trusted external state from the last durable event, if resuming. */
|
|
50
|
+
readonly checkpoint?: AuditChainCheckpoint;
|
|
51
|
+
}
|
|
52
|
+
export interface HmacAuditChain {
|
|
53
|
+
/** Append and sign one previously unchained OCSF event synchronously. */
|
|
54
|
+
append(event: CredentialAuditEvent): ChainedCredentialAuditEvent;
|
|
55
|
+
/**
|
|
56
|
+
* Switch the signing key at an emission boundary.
|
|
57
|
+
*
|
|
58
|
+
* The first later event uses the new key ID while linking to the last event
|
|
59
|
+
* produced with the old key. IDs and key material can never be reused within
|
|
60
|
+
* one chain, including after resuming from a trusted checkpoint.
|
|
61
|
+
*/
|
|
62
|
+
rotate(next: AuditChainKey): void;
|
|
63
|
+
/** Latest value-free state, or `null` before the first event. */
|
|
64
|
+
checkpoint(): AuditChainCheckpoint | null;
|
|
65
|
+
/** Zero the chain's private key copy and permanently close the writer. */
|
|
66
|
+
destroy(): void;
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* RFC 8785 JSON Canonicalization Scheme for the JSON data model used by OCSF.
|
|
70
|
+
*
|
|
71
|
+
* Objects are plain data objects, names are ordered by UTF-16 code units, and
|
|
72
|
+
* strings/numbers use ECMAScript JSON serialization. Values outside I-JSON
|
|
73
|
+
* (cycles, sparse arrays, accessors, non-finite numbers, lone surrogates,
|
|
74
|
+
* `undefined`, `bigint`, functions, symbols) fail closed.
|
|
75
|
+
*/
|
|
76
|
+
export declare function canonicalizeAuditEvent(value: unknown): string;
|
|
77
|
+
/** Create one synchronous, process-local HMAC-SHA-256 audit chain. */
|
|
78
|
+
export declare function createHmacAuditChain(options: CreateHmacAuditChainOptions): HmacAuditChain;
|
|
79
|
+
export type AuditChainVerificationFailureCode = "MALFORMED_EVENT" | "UNSUPPORTED_ALGORITHM" | "UNSUPPORTED_CANONICALIZATION" | "UNANCHORED_SEGMENT" | "CHAIN_ID_MISMATCH" | "INVALID_SEQUENCE" | "SEQUENCE_GAP_OR_REORDER" | "PREVIOUS_EVENT_MISMATCH" | "PREVIOUS_HASH_MISMATCH" | "UNKNOWN_KEY_ID" | "KEY_RESOLUTION_FAILED" | "INVALID_KEY" | "KEY_ID_REUSE" | "KEY_ID_MATERIAL_MISMATCH" | "KEY_MATERIAL_REUSE" | "HASH_MISMATCH" | "FINAL_CHECKPOINT_MISMATCH";
|
|
80
|
+
export interface AuditChainVerificationFailure {
|
|
81
|
+
readonly code: AuditChainVerificationFailureCode;
|
|
82
|
+
readonly message: string;
|
|
83
|
+
readonly index: number;
|
|
84
|
+
readonly sequenceNumber?: number;
|
|
85
|
+
}
|
|
86
|
+
export type AuditChainVerificationResult = {
|
|
87
|
+
readonly ok: true;
|
|
88
|
+
readonly value: {
|
|
89
|
+
readonly verifiedCount: number;
|
|
90
|
+
readonly rotations: number;
|
|
91
|
+
readonly checkpoint: AuditChainCheckpoint | null;
|
|
92
|
+
};
|
|
93
|
+
} | {
|
|
94
|
+
readonly ok: false;
|
|
95
|
+
readonly error: AuditChainVerificationFailure;
|
|
96
|
+
};
|
|
97
|
+
export interface VerifyAuditChainOptions {
|
|
98
|
+
/** Trusted key lookup. Returned key bytes remain owned by the resolver. */
|
|
99
|
+
readonly resolveKey: (keyId: string) => Uint8Array | undefined;
|
|
100
|
+
/** Expected chain identity, required to reject whole-chain substitution. */
|
|
101
|
+
readonly expectedChainId?: string;
|
|
102
|
+
/** Required when verification starts after the genesis event. */
|
|
103
|
+
readonly initialCheckpoint?: AuditChainCheckpoint;
|
|
104
|
+
/** External terminal anchor used to detect deletion of a chain suffix. */
|
|
105
|
+
readonly expectedFinalCheckpoint?: AuditChainCheckpoint;
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
108
|
+
* Verify one ordered chain segment without mutating records or key material.
|
|
109
|
+
*
|
|
110
|
+
* The function returns a discriminated failure for hostile input. It requires
|
|
111
|
+
* genesis (`sequence=1`, no `prev_event`) unless a trusted initial checkpoint
|
|
112
|
+
* anchors a later segment. An optional trusted final checkpoint detects suffix
|
|
113
|
+
* deletion; no hash-chain algorithm can detect deletion of the entire log by
|
|
114
|
+
* looking only at that log.
|
|
115
|
+
*/
|
|
116
|
+
export declare function verifyHmacAuditChain(events: readonly unknown[], options: VerifyAuditChainOptions): AuditChainVerificationResult;
|
|
117
|
+
//# sourceMappingURL=hmac-chain.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"hmac-chain.d.ts","sourceRoot":"","sources":["../../src/audit/hmac-chain.ts"],"names":[],"mappings":"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"}
|