@centient/secrets
v0.11.0
Published
Cross-platform secrets vault with AES-256-GCM encryption and platform-native key storage
Maintainers
Readme
@centient/secrets
Cross-platform secrets vault with AES-256-GCM encryption and platform-native key storage.
Before you deploy this: read the threat model. It states what the library defends against, what it explicitly does not defend against (including two gaps that are live on the default path — the shared
centient-vaultKeychain master-key item, and the master key transiting argv on the macOS Keychain key write), where the trust boundaries are, and a deployment checklist.
Daemons / long-running processes: see Session-backed vault (
openVault) 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.
Creating a session vault: use
createVault({ path, key }). It composes the initial AAD-bound ciphertext and rollback sidecar without exposing their private wire format. It is non-destructive, never manages provider state, and refuses incomplete vault/sidecar pairs. See the first-use guide.
Installation
npm install @centient/secretsOr with pnpm:
pnpm add @centient/secretsFeatures
- AES-256-GCM authenticated encryption for secrets at rest
- Platform-native key storage (macOS Keychain, Linux secret-service)
- Pluggable key providers (Keychain, 1Password, passphrase)
- Credential vault with session management, plus an opt-in 1Password credential backend
- Instance-scoped
SecretsClientfactories with composable policy middleware - Environment detection (CI, Docker, SSH, headless, agent)
- Built-in CLI for interactive secret management
Quick Start
The client factory is the primary API when a consumer needs an explicit storage provider, caller identity, or policies. Each client snapshots its policy-stack membership, so two consumers in one process cannot accidentally add or remove one another's enforcement. Policy instances may retain their own internal state.
import {
KeychainVault,
auditTrail,
createSecretsClient,
type CredentialAuditEvent,
} from "@centient/secrets";
const auditBuffer: CredentialAuditEvent[] = [];
const client = createSecretsClient({
provider: new KeychainVault(),
policies: [
auditTrail({
sink: (event) => auditBuffer.push(event),
includeReads: true,
}),
],
});
await client.storeCredential("my-service.api-key", "<your-api-key>", {
caller: { id: "my-service", kind: "service" },
});
const value = await client.getCredential("my-service.api-key", {
caller: { id: "my-service", kind: "service" },
});
await client.deleteCredential("my-service.api-key", {
caller: { id: "my-service", kind: "service" },
});SecretsProvider is the credential-value storage SPI used by the factory.
The older VaultBackend name remains as a deprecated type alias, so existing
implementations continue to compile. This is separate from KeyProvider, which
locates the master key used to open an encrypted session vault.
Policies use onion ordering: before runs top-to-bottom and after runs
bottom-to-top. A throwing before fails closed without contacting the provider;
the rejecting policy and every previously entered policy still receive the
rejection event while later policies do not run. Operation metadata includes the
operation, key or prefix, and optional caller identity, but never the credential
value. Because auditTrail() promises to observe policy denials, place it before
every policy that has a before hook. Client construction and legacy policy
replacement reject an unsafe order instead of silently leaving denials unaudited.
Audit telemetry: OpenTelemetry + OCSF
Installing an auditTrail() policy maps every value-free credential outcome —
including provider failures and policy denials — to an OCSF 1.9.0 Entity
Management record. auditTrail() defaults to an OpenTelemetry sink; audit is
not globally enabled merely by importing the package.
import { auditTrail, createSecretsClient } from "@centient/secrets";
const client = createSecretsClient({
provider,
policies: [auditTrail()],
});@centient/secrets depends on @opentelemetry/api, not an SDK or exporter.
The application owns tracer-provider, sampler, exporter, collector, and shutdown
configuration. The sink reports a non-recording or unsampled span as degraded;
for an audit pipeline, configure an always-on audit sampler and force-flush the
application's SDK before shutdown.
The released OCSF schema corrects an older ADR-002 label: 3 is the Identity &
Access Management category and 3004 is the Entity Management class; there
is no released class named Credential Activity. Stores are upserts and
enumeration has no 3004 activity, so both use activity_id: 99 (Store or
Enumerate) instead of claiming a create/update distinction the provider SPI
cannot observe. Policy denial is action_id: 2 with status_id: 2.
Sink migration: the pre-1.0 audit seam passed its internal SecretsEvent
directly to a callback. A sink now receives CredentialAuditEvent. Read the
credential key from entity.name, provider from entity.data.vault_name, raw
event classification from unmapped["centient.secrets.event_type"], and latency
from unmapped["centient.secrets.operation.duration_ms"]. Credential values and
provider/policy exception text are not copied into the OCSF record.
Non-OTel deployments can use the JSONL or RFC 5424 syslog sinks. Both enqueue in
emission order, bound their queues, report background failures, and expose
flush()/close() so a caller can observe delivery failure:
import { auditTrail, createSecretsClient } from "@centient/secrets";
import { jsonlAuditSink } from "@centient/secrets/sinks";
const sink = jsonlAuditSink({ filePath: "/var/log/my-service/secrets.jsonl" });
const client = createSecretsClient({ provider, policies: [auditTrail({ sink })] });
// ...credential operations...
await sink.flush?.();JSONL contains bare OCSF records. Syslog serializes the same record as the RFC
5424 message body. New JSONL files/directories default to owner-only modes
(0o600/0o700); existing permissions are not silently changed. UDP syslog's
flush() confirms local socket acceptance, not remote persistence, and UDP does
not provide transport encryption or authentication—use a local syslog daemon or
inject a protected sender for a remote hop. Neither sink routes through
@centient/logger's
AuditWriter or the SDK's remote AuditResource: both existing seams have a
different closed event schema and async/failure contract. Keeping the
AuditSink boundary structural prevents either package from silently reshaping
or truncating a signed record.
HMAC record-integrity chains
Create an optional process-local chain with a dedicated 32-byte audit key. The
consumer must load that key from its deployment trust store and inject it; the
library never reads a file/environment/provider implicitly, silently generates
a key, or reuses/derives from the vault-encryption key managed by KeyProvider.
import {
auditTrail,
createHmacAuditChain,
createSecretsClient,
verifyHmacAuditChain,
} from "@centient/secrets";
import { jsonlAuditSink } from "@centient/secrets/sinks";
const chain = createHmacAuditChain({
chainId: "my-service-secrets",
keyId: "audit-key-2026-08",
key: auditKeyBytes, // exactly 32 bytes, loaded by the application
});
const sink = jsonlAuditSink({ filePath: "/var/log/my-service/secrets.jsonl" });
const client = createSecretsClient({
provider,
policies: [auditTrail({ sink, chain })],
});
// Rotate only at an explicit emission boundary; IDs and key material may never be reused.
chain.rotate({ keyId: "audit-key-2026-09", key: nextAuditKeyBytes });
await sink.flush?.();
const anchor = chain.checkpoint();
// Persist `anchor` outside the audit log's trust domain.Each chained event uses OCSF 1.9's Record Integrity profile:
metadata.sequence, a chain UID, the current HMAC fingerprint, and the previous
event reference. verifyHmacAuditChain() accepts a trusted key resolver; bind
verification to the deployment's trusted expectedChainId so another valid
chain cannot be substituted. The verifier
returns a discriminated result rather than throwing on hostile records. It
detects content mutation (including the final event), insertion, duplication,
internal deletion, reorder, unknown/wrong keys, and invalid rotations. Supply a
trusted initial checkpoint when verifying a resumed segment and an expected
final checkpoint to detect prefix/tail deletion; HMAC alone cannot prove that
an attacker did not delete the whole log. Checkpoints carry chain-scoped,
HMAC-derived key fingerprints (never key bytes), so historical key material
cannot be reintroduced under a fresh ID after restart. A global multi-process sequence needs
an external serialized coordinator—each independent process should otherwise
use its own chainId.
The module-level functions remain the simple, backwards-compatible path. They delegate through the process's default client and the legacy global policy configuration:
import { storeCredential, getCredential, deleteCredential } from "@centient/secrets";
await storeCredential("my-service.api-key", "<your-api-key>");
const value = await getCredential("my-service.api-key");
await deleteCredential("my-service.api-key");Encryption Utilities
import { encrypt, decrypt } from "@centient/secrets";
const key = crypto.randomBytes(32);
const encrypted = encrypt("sensitive data", key);
const decrypted = decrypt(encrypted, key);Platform Detection
import { isCIEnvironment, isDockerContainer, isAgentEnvironment } from "@centient/secrets";
if (isCIEnvironment()) {
// Use environment variable fallback
}Auth CLI messages
AUTH_MESSAGES is the shared, i18n-ready catalog of user-visible strings for
the credential lifecycle a CLI drives on top of this vault — login, logout,
auth status, auth refresh, device flow, and api-key entry. Import it; do not
copy it. It is public precisely so a consuming CLI stops maintaining a fork that
drifts from this one.
import { AUTH_MESSAGES, type AuthMessages } from "@centient/secrets";
process.stderr.write(AUTH_MESSAGES.error.vaultWriteFailed + "\n");
process.stderr.write(AUTH_MESSAGES.info.loginPrompt(verificationUri) + "\n");
process.stderr.write(AUTH_MESSAGES.warning.tokenExpiringSoon(5) + "\n");No value interpolates at the call site: fixed messages are string constants and
parameterized ones are template functions returning the finished string, so a
translation layer can replace the catalog wholesale. AuthMessages is the
catalog's type, for typing such a table.
Scope. The catalog covers the auth lifecycle above, plus the one
credential-storage warning this package itself emits
(warning.envVaultNoStorage, from the read-only EnvVault fallback). It does
not cover the centient secrets … operator CLI (runSecrets) or the
[secrets] … vault diagnostics — those are command-specific and carry their own
next-step lines, so they stay at their call sites by design.
Key Providers
| Provider | Platform | Description |
|----------|----------|-------------|
| KeychainProvider | macOS/Linux | Uses OS keychain (Keychain Access / secret-service) |
| OnePasswordProvider | Any | Uses 1Password CLI for team secret sharing |
| PassphraseProvider | Any (interactive TTY) | Derives the vault key from a typed passphrase via scrypt — no OS keychain required |
Provider auto-detection prefers OS-backed storage: 1Password, then Keychain,
then passphrase as the last fallback. Set secrets.provider: "passphrase" in
~/.centient/config.json to select it explicitly.
Credential storage backends
The key layer above decides where the vault encryption key lives. This is the separate credential layer: where secret values are stored.
| Backend | Platform | Selection |
|---|---|---|
| KeychainVault | macOS | auto |
| WindowsVault | Windows / WSL | auto |
| LibsecretVault | Linux | auto |
| GpgVault | Linux / WSL | auto |
| EnvVault | Any | auto (last resort) |
| OnePasswordVault | Any (needs op) | explicit opt-in only |
The first five form an auto-cascade, picked by detect() in that order.
OnePasswordVault is deliberately outside it: having the 1Password CLI
installed is not consent to route credentials into your personal vault, so it is
reachable only when you ask for it by name (ADR-004).
// centient config file
{
"secrets": {
"provider": "keychain", // KEY layer — key stays in the Keychain
"backend": "1password", // VALUE layer — credentials in 1Password
"onePasswordBackend": {
"vault": "centient-credentials", // REQUIRED — no default
"tag": "centient" // optional
}
}
}Environment equivalents, which take precedence:
CENTIENT_SECRETS_BACKEND=1password and CENTIENT_OP_VAULT=<name>.
Two behaviours worth knowing:
- No default vault, and it fails closed. Unlike the key block (which defaults
to
Private), an unsetonePasswordBackend.vaultunder an explicitbackend: "1password"is an error. Guessing could write credentials into a vault you did not intend. - An explicit choice is never silently substituted. If you name
1passwordandopturns out to be missing or unauthenticated, startup throws rather than quietly falling back to the Keychain — otherwise your secrets would land somewhere other than where you said.
Secret values are written over stdin (op item create -), never as argv, so
they never appear in ps. Only key names are cached (5s TTL, mirroring the
Keychain backend); values are never cached, so a rotated or revoked credential is
never served from memory.
Key constraint. This backend enforces isValidKey (lowercase alphanumeric
with - or . separators, 2–64 chars) on every operation, and refuses anything
else rather than storing it. Reads address the value as
op://<vault>/<key>/password, which is path-structured: a key containing /
would store fine — a 1Password item title is just a string — and then re-parse on
read into a different item and field, so the write would be silently unreadable.
Refusing is the better failure; a caller believing a credential is saved when it
cannot be read back is worse than a caller told no.
Environment fallback key mapping
EnvVault is the read-only terminal backend for hosts with no secure credential
store. Every valid logical credential key has exactly one environment-variable
name, so a miss means that variable is genuinely unset rather than that the
backend does not know how to address the key. The historical mapping is retained:
| Credential key | Environment variable |
|---|---|
| auth-token | ENGRAM_API_KEY |
| sync-peers.dek | CENTIENT_SECRET_SYNC_2DPEERS_2EDEK |
For non-auth keys, alphanumerics are uppercased, - is escaped as _2D, . as
_2E, and the result is prefixed with CENTIENT_SECRET_. _ is forbidden by
the credential-key grammar, so the mapping stays reversible even for adjacent
separators. Use the exported helper instead of reimplementing it:
import { EnvVault, createSecretsClient, credentialKeyToEnvName } from "@centient/secrets";
credentialKeyToEnvName("sync-peers.dek");
// => "CENTIENT_SECRET_SYNC_2DPEERS_2EDEK"
const client = createSecretsClient({ provider: new EnvVault() });An unset variable probes as absent. A defined-but-empty variable returns
null on retrieval and probes as failed, because zero bytes are not a usable
credential. store() remains read-only and returns false; delete() remains
an idempotent no-op. Environment values are unencrypted, inherited by child
processes, and may be readable through /proc/<pid>/environ; this fallback is
configuration plumbing, not secure storage.
Per-consumer vault keys
By default KeychainProvider targets a single shared Keychain item
(service="centient-vault", account="vault-key"), so every consumer on a
machine unlocks its vault with the same master key. Two complementary options
let each consumer use its own key (issue #80). Both are additive — with no
options the behaviour is byte-identical to before, and existing vaults keep
opening.
This is opt-in, and the shared item is still the default. Until you pass one
of the options below, anything that can read the centient-vault Keychain item
unlocks every consumer's vault on the machine — see
threat model §4.1.
Name your own Keychain item — the lightweight path. Pass keychain to
openVault() (threaded into internal provider resolution) so your consumer's
master key lives under its own Keychain item:
import { openVault } from "@centient/secrets";
// Encrypts/decrypts this vault under the "burnrate-vault" Keychain item
// instead of the global "centient-vault" item.
const vault = await openVault({ keychain: { service: "burnrate-vault" } });Or construct the provider directly:
import { KeychainProvider } from "@centient/secrets";
const provider = new KeychainProvider({ service: "burnrate-vault", account: "k" });Inject your own provider — full control, and the headless-testability path.
Pass keyProvider and openVault() uses it verbatim, skipping internal
resolution (config + auto-detection) entirely. This lets you drive openVault()
in tests against a throwaway in-memory provider with no real Keychain:
import { openVault, type KeyProvider } from "@centient/secrets";
const stub: KeyProvider = {
name: "keychain",
getKey: () => myTestMasterKey, // 32-byte Buffer
storeKey: () => true,
deleteKey: () => true,
};
const vault = await openVault({ keyProvider: stub });A custom provider can also wrap any backend (remote KMS, HSM, env-injected key)
as long as it implements the KeyProvider interface.
KeyProvider is intentionally separate from SecretsProvider, the
credential-value storage SPI. A key provider establishes or retrieves the one
master key used to unlock an encrypted session vault; a secrets provider stores
the individual credential values. The same technology may fill both roles—for
example, OnePasswordProvider and OnePasswordVault—but their instances,
configuration, key spaces, and operation lifecycles remain independent.
Passphrase provider
For hosts without an OS keychain or 1Password CLI (e.g. a headless Linux box
over SSH), the vault key is derived from a passphrase typed at an interactive
terminal using scrypt (N=2^17, r=8, p=1, 32-byte key — ~128 MB memory cost
per derivation, in line with current OWASP guidance). The passphrase and the
derived key are never persisted. A sidecar file (vault.passphrase.json,
mode 0600, beside the vault) stores only the salt, the KDF parameters, and
an HMAC-SHA256 verifier used to detect a wrong passphrase without revealing
the key.
Security tradeoffs vs OS-backed providers — choose deliberately:
- Passphrase strength is the security ceiling. Keychain keys are random 256-bit values guarded by the OS; a passphrase-derived key is only as strong as the passphrase. The scrypt cost is the sole brake on brute force.
- The sidecar enables offline guessing if exfiltrated. An attacker holding
vault.passphrase.json(or the vault file) can test candidate passphrases offline at ~one guess per 128 MB-scrypt derivation. Use a long, high-entropy passphrase. - No human-presence guarantee. Unlike Keychain with Touch ID, typing a passphrase proves knowledge, not presence; it cannot satisfy policies that require fresh per-operation human auth.
- Interactive TTY required — fails closed otherwise. In CI, agent, or other non-interactive contexts the provider refuses to prompt and unlock fails with an actionable error. Configure keychain/1Password for non-interactive use.
- Unlock blocks the event loop. Key derivation is synchronous (~hundreds
of ms); daemons should call
openVault()once at startup, before entering their hot loop.
Compatibility floor for consumers
Several releases of this package closed defects whose only symptom, at the old version, is silence: a denied credential operation that leaves no audit trace, a keychain write that reports success into a keychain the current context's reader cannot see, a malformed key that stores on one backend and reads as absent on the next. A consumer running such a version cannot learn that from the package — the behaviour looks fine and the logs look clean.
So the provider publishes the list. COMPATIBILITY_FLOOR is a data table of
every known behavioural gap, keyed by the version that closed it, and
assessCompatibility() turns an installed version into a grade a doctor
command can print.
import { assessCompatibility, SECRETS_PACKAGE_VERSION } from "@centient/secrets";
const assessment = assessCompatibility(SECRETS_PACKAGE_VERSION);
if (assessment.grade !== "ok") {
console.warn(assessment.summary);
for (const gap of assessment.openGaps) {
console.warn(` [${gap.severity}] ${gap.symptom}`);
console.warn(` fixed in ${gap.closedIn} (${gap.reference})`);
if (gap.adoptionNote) console.warn(` on adoption: ${gap.adoptionNote}`);
}
}SECRETS_PACKAGE_VERSION is the version of the build you actually imported, so
the assessment describes the code in your tree rather than whatever is latest on
the registry. It is kept in step with package.json by the release flow and
asserted by a test, so it cannot drift into a wrong answer.
The grades
| grade | Meaning |
|---|---|
| ok | At or above every known fix. openGaps is empty. |
| degraded | Assessable, and one or more known gaps are open. openGaps lists them, oldest fix first; highestSeverity is the worst. |
| 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. |
| unknown | The version could not be parsed. reason says why. |
This is a graded floor, never a boot-time refusal
Nothing in this surface runs at import, nothing throws from a constructor, and
no vault path consults it. Every function is pure, total and advisory:
malformed input comes back as a typed unknown grade carrying the reason, not
as an exception. A package that refuses to load because its caller is old turns
a documentation problem into an outage — so grade, report, and let the consumer
decide.
Auditing a manifest pin instead of an install
assessCompatibility() answers "what is open in the build I imported".
assessPin() answers "what could be open in any build this pin admits", by
grading the lowest version the range allows — which is also what a lockfile
that has never been refreshed is most likely holding:
assessPin("^0.6.0").openGaps.map((g) => g.id);The range grammar assessPin() accepts
The whole range is matched against an anchored grammar, so an unsupported form is rejected as unsupported rather than partially interpreted. Exactly four forms resolve:
| Form | Example | Resolves to |
|---|---|---|
| caret | ^0.6.0 | 0.6.0 |
| tilde | ~0.9.1 | 0.9.1 |
| inclusive lower bound | >=0.8.0 | 0.8.0 |
| exact | 0.10.0 | 0.10.0 |
The version component is a full major.minor.patch. A conventional leading v
(v0.5.0), horizontal whitespace after the operator (>= 0.8.0) and around the
whole range, and a -prerelease suffix are all accepted; the pre-release is
kept, because rounding ^1.0.0-rc.1 up to 1.0.0 would credit a consumer
for a release it is not running.
Everything else returns grade: "unknown" with a reason naming the accepted
grammar: composite ranges (>=0.6.0 <0.8.0), || unions, hyphen ranges
(1.0.0 - 2.0.0), wildcards (*), x-ranges (1.x, 1.2.x), partial versions
(1.2), upper and exclusive bounds (<0.9.0, >0.9.0), protocol pins
(workspace:*, npm:…, file:…, a git URL), build metadata (^1.2.3+build,
which a parser discards rather than interprets), and any form carrying a
trailing token. A guess would grade the wrong version and hand back a clean bill
of health for a pin nobody actually checked.
Malformed input is graded, never thrown
assessCompatibility() and assessPin() narrow their argument through the same
guard before anything parses it, so a JS caller — or a JSON.parsed manifest,
or an any at a module boundary — that passes null, undefined, a number or
an object gets the documented grade: "unknown" result carrying a reason,
never an exception. installedVersion and summary are strings in that case
too, so the echo cannot hand back a value its declared type forbids.
Adding an entry
A release earns a row when it closes a defect whose pre-fix symptom is silent.
A fix a consumer would notice on its own does not need one. Each entry carries
an id (stable — consumers may suppress by it), the closedIn version, a
severity, a one-line consumer-facing symptom, the reference issue, and an
adoptionNote whenever adopting the fix is not a pure no-op. The table is
ordered oldest fix first; tests/compatibility.test.ts pins the ordering, the
version parity, and the grade each known consumer pin earns.
License
MIT
