npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@centient/secrets

v0.11.0

Published

Cross-platform secrets vault with AES-256-GCM encryption and platform-native key storage

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-vault Keychain 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/secrets

Or with pnpm:

pnpm add @centient/secrets

Features

  • 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 SecretsClient factories 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 unset onePasswordBackend.vault under an explicit backend: "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 1password and op turns 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