@push.rocks/smartsecret
OS-backed secret storage plus strict Linux kernel-keyring, sealed-file, and envelope APIs for Node.js.
Issue Reporting and Security
For reporting bugs, issues, or security vulnerabilities, please visit community.foss.global/. This is the central community hub for all issue reporting. Developers who sign and comply with our contribution agreement and go through identification can also get a code.foss.global/ account to submit Pull Requests directly.
Install
To install @push.rocks/smartsecret, use pnpm:
pnpm install @push.rocks/smartsecret
Usage
@push.rocks/smartsecret provides a unified API for storing and retrieving secrets. It automatically selects the best available backend for the current platform: macOS Keychain on macOS, secret-tool (libsecret / GNOME Keyring) on Linux, or an AES-256-GCM encrypted file as a universal fallback.
Basic Setup
import { SmartSecret } from '@push.rocks/smartsecret';
// Create an instance with default settings
const secretStore = new SmartSecret();
// Or specify a custom service name and vault path
const customSecretStore = new SmartSecret({
service: 'my-application',
vaultPath: '/path/to/custom/vault.json',
});
The service option acts as a namespace, isolating secrets so that different applications do not collide. It defaults to 'smartsecret' when omitted.
The vaultPath option only applies to the encrypted-file backend and controls where the vault JSON file is stored. It defaults to ~/.config/smartsecret/vault.json.
Storing a Secret
await secretStore.setSecret('api-key', 'sk-abc123xyz');
If a secret with the same account name already exists under the configured service, it is overwritten.
Retrieving a Secret
const apiKey = await secretStore.getSecret('api-key');
if (apiKey !== null) {
console.log('Retrieved secret:', apiKey);
} else {
console.log('Secret not found');
}
Returns null when no secret exists for the given account.
Deleting a Secret
const wasDeleted = await secretStore.deleteSecret('api-key');
console.log(wasDeleted); // true if the secret existed and was removed
Returns false if the secret did not exist.
Listing Accounts
const accounts = await secretStore.listAccounts();
console.log(accounts); // e.g. ['api-key', 'db-password', 'oauth-token']
Returns an array of account names that have stored secrets under the configured service.
Checking the Active Backend
const backendType = await secretStore.getBackendType();
console.log(backendType);
// 'macos-keychain' | 'linux-secret-service' | 'file-encrypted'
This is useful for logging or diagnostics to understand which storage mechanism is in use at runtime.
Service-Based Isolation
Different SmartSecret instances with different service names maintain completely separate secret namespaces, even when sharing the same underlying storage:
const appSecrets = new SmartSecret({ service: 'my-app' });
const ciSecrets = new SmartSecret({ service: 'ci-pipeline' });
await appSecrets.setSecret('token', 'app-token-value');
await ciSecrets.setSecret('token', 'ci-token-value');
const appToken = await appSecrets.getSecret('token'); // 'app-token-value'
const ciToken = await ciSecrets.getSecret('token'); // 'ci-token-value'
SmartSecret Keyring
SmartSecretKeyring is a separate Node.js API for DEK/KEK envelopes. It does not change SmartSecret or any backend API. The keyring is intentionally Linux-only and fails closed on other platforms.
The consumer supplies an ordered key configuration containing exactly one active KEK. Active and retired KEKs are read from absolute credential paths. Each credential must be a regular, non-symlinked, exact 32-byte file without group or world write permissions. Every path component is checked for symlinks, and the loaded key must match its configured SHA-256 fingerprint. Revoked descriptors never contain credential paths or fingerprints.
import { SmartSecretKeyring } from '@push.rocks/smartsecret';
const keyring = await SmartSecretKeyring.create({
keyringId: 'billing.production',
keys: [
{
version: 1,
status: 'retired',
credentialPath: '/run/credentials/billing-kek-v1',
expectedFingerprint: 'sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef',
createdAt: 1_700_000_000_000,
activatedAt: 1_700_000_100_000,
retiredAt: 1_710_000_000_000,
escrowReference: 'escrow.billing-v1',
escrowedAt: 1_700_000_050_000,
recoveryTestedAt: 1_700_000_075_000,
},
{
version: 2,
status: 'active',
credentialPath: '/run/credentials/billing-kek-v2',
expectedFingerprint: 'sha256:abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789',
createdAt: 1_709_000_000_000,
activatedAt: 1_710_000_000_000,
},
],
});
const context = new TextEncoder().encode('tenant:acme/secret:database-password');
const plaintext = new TextEncoder().encode('secret value');
const envelope = await keyring.encryptEnvelope({
envelopeId: 'secret.database-password',
plaintext,
context,
});
const decrypted = await keyring.decryptEnvelope({
envelope,
expectedEnvelopeId: 'secret.database-password',
context,
});
const rewrapped = await keyring.rewrapEnvelope({
envelope,
expectedEnvelopeId: 'secret.database-password',
context,
targetVersion: 2,
});
const recoveryResult = await keyring.recoverySelfTest(2);
const inspection = keyring.inspect();
keyring.destroy();
encryptEnvelope() always uses the sole active KEK. decryptEnvelope() permits active and retired KEKs, but rejects revoked and unknown versions. rewrapEnvelope() authenticates the current wrapper and context, then calls SmartCrypto's DEK rewrap operation without decrypting the payload. It preserves the payload nonce, ciphertext, and tag exactly.
The profile is fixed to smartsecret-aes-256-gcm-dek-kek-v1. Payloads may contain 0 through 524,288 bytes. Context is mandatory and may contain 1 through 65,536 bytes. Identifiers use the pattern ^[A-Za-z0-9][A-Za-z0-9:._-]{0,199}$ and are limited to 200 UTF-8 bytes.
Key loading is an immutable per-instance snapshot. Replacing a credential file does not reload an existing keyring; create a new instance and swap it at the application boundary. destroy() marks an instance unusable and best-effort wipes retained key arrays. Operation-owned key, plaintext, and context copies are also wiped in finally blocks. JavaScript runtimes, garbage collectors, and native cryptographic implementations can retain copies outside package control, so zeroization is best effort rather than a hard memory-erasure guarantee.
The package never persists envelopes. The consumer owns durable envelope storage, compare-and-swap coordination during rewrap, and any metadata updates after recovery testing.
SmartSecret Kernel Store
SmartSecretKernelStore is a separate, fail-closed API for small secrets in the Linux kernel keyring. It does not change the legacy SmartSecret backend selection or add a fallback to it. The store requires Node.js 24 or 25 on Linux x64 and starts only the package-owned static worker at dist_rust/smartsecret-kernel_linux_amd64. It never searches PATH, accepts an environment override, invokes a shell, probes Secret Service, or persists secret values to the filesystem.
import { SmartSecretKernelStore } from '@push.rocks/smartsecret';
const store = await SmartSecretKernelStore.create({
service: 'example.application',
});
try {
await store.setEntry('oidc-client-secret', new TextEncoder().encode('secret'));
const value = await store.getEntry('oidc-client-secret');
const deleted = await store.deleteEntry('oidc-client-secret');
value?.fill(0);
} finally {
await store.close();
}
Service and account strings must contain valid Unicode scalar values and encode to 1 through 1,024 UTF-8 bytes. Values may contain 0 through 16,384 bytes. The stored value is framed with a fixed version and exact length before publication. Existing service rings and entries are accepted only when their type, description, owner, and private permissions match the package contract. Kernel per-user quotas may still reject writes before this per-entry limit is reached; the store reports the kernel failure and never falls back to filesystem storage.
setEntry() copies its input before dispatch, and getEntry() returns a fresh byte array. The caller still owns its input and returned copies and should wipe them when no longer needed. JavaScript and operating-system buffers can retain additional copies, so this remains best-effort zeroization rather than guaranteed memory erasure.
The store uses one persistent-user-owned service ring and stages unpublished objects in the process keyring. An inherited session link is preserved and revalidated, but it cannot override or conflict with the persistent root. A revoked inherited session keyring, which can remain after PAM logout, is treated as absent because it is optional and unusable; any failure to establish or validate the required process and persistent roots still fails closed. Publication is verified before staging ownership is removed. Every operation also acquires a service-wide NamedMutex, which coordinates cooperating processes running as the same OS identity on the same machine.
Generic and legacy searches classify missing, revoked, or expired matches as absent only after the authoritative root revalidates. An unusable root remains a fail-closed kernel error rather than an absent entry.
Kernel keyring permissions are an isolation boundary between OS identities, not between processes running under the same UID. Same-UID processes with access to the relevant keyrings may be able to read entries. Persistent keyrings can expire under kernel policy and are cleared by reboot. Applications must treat null as absence, not as evidence that a secret existed previously.
Operation timeouts default to 5 seconds and may be set from 1 through 60,000 milliseconds. A timeout or abort covers queueing, mutex acquisition, and the worker request under one monotonic deadline. If a mutating request is interrupted after dispatch, or publication/release cannot be confirmed, the store rejects with a SmartSecretKernelStoreError whose code is MUTATION_OUTCOME_UNKNOWN, terminates the worker, and remains poisoned. Read-side worker integrity failures also poison and terminate the store. Always await close(); it waits for already-reserved operations and confirms worker termination.
Sealed file store
SmartSecretSealedFileStore is a Linux-only API that composes a caller-owned SmartSecretKernelStore for data sets that exceed the kernel's per-user key quota. It stores one 32-byte master key in the kernel keyring and one authenticated AES-256-GCM envelope per account in a dedicated private directory supplied by the caller.
import {
SmartSecretKernelStore,
SmartSecretSealedFileStore,
} from '@push.rocks/smartsecret';
const kernelStore = await SmartSecretKernelStore.create({
service: 'example.application',
});
try {
const sealedStore = await SmartSecretSealedFileStore.create({
kernelStore,
storeId: 'oauth-credentials',
directoryPath: '/absolute/private/application/directory/credentials',
});
try {
await sealedStore.setEntry('account-a', new TextEncoder().encode('secret'));
const value = await sealedStore.getEntry('account-a');
value?.fill(0);
} finally {
await sealedStore.close();
}
} finally {
await kernelStore.close();
}
The directory is required to be owned by the effective user and mode 0700; envelope and manifest files are mode 0600. Every directory component must be owned by root or the effective user and must not be group- or world-writable, except for root-owned sticky directories such as /tmp. Directory traversal, creation, and store I/O are anchored to open directory descriptors, and final components use O_NOFOLLOW. Writes use random exclusive temporary files, file and directory fsync, and atomic replacement. Reads reject unexpected owners or permissions, malformed envelopes, identity mismatches, oversized ciphertext, and authentication failures. The filesystem boundary, like the kernel keyring, trusts other processes running under the same UID.
The non-secret manifest binds the directory to the kernel master-key fingerprint. If the kernel key is lost or expires while any manifest, envelope, or temporary artifact remains, create() fails with MASTER_KEY_UNAVAILABLE and never silently generates a replacement. SmartSecretSealedFileStore.reset() is an explicit destructive recovery operation: after verifying any existing manifest belongs to the requested service and store ID, it removes only the store-owned manifest, envelope, and temporary files, removes the old kernel entry if present, and creates a new empty store.
Each store instance admits at most 64 operations at once. Each admitted operation has one 60-second monotonic deadline covering the local operation queue and cross-process mutex acquisition. The mutex is directory-wide, so different service or store identities targeting the same directory serialize with each other. Work already running after mutex acquisition is not interrupted by that admission deadline. close() rejects new operations, drains admitted operations, and then best-effort wipes the retained master-key copy.
DevIdP v1 keyutils migration
The two DevIdP migration methods are intentionally narrow. They are available only on a store created with service global.idp.devidp and accept only accounts matching v1:[a-f0-9]{64}:
const source = await store.readDevIdpV1Legacy(legacyAccount);
if (source) {
// Parse the DevIdP envelope and durably verify every destination first.
await store.deleteDevIdpV1Legacy(source.receipt);
}
readDevIdpV1Legacy() returns 1 through 16,384 opaque bytes plus a one-use, store-bound receipt. Deletion reacquires the same mutex and atomically rechecks the source serial, root membership, byte length, and SHA-256 digest inside one worker command. A replacement or changed value is not deleted and rejects with a SmartSecretKernelStoreError whose code is SOURCE_CHANGED. Linux keyutils does not provide compare-and-delete against an uncooperative writer that updates the same serial, so the legacy writer must be stopped or otherwise operationally quiescent before migration.
This migration surface covers only a legacy record proven to have been written through the @napi-rs/keyring keyutils fallback. It does not probe or migrate Secret Service. If Secret Service could own the source, that source-specific migration must be implemented at its owning layer before treating a kernel-keyring miss as authoritative.
API Reference
SmartSecret
The main class. Instantiate it to store and retrieve secrets.
Constructor
new SmartSecret(options?: ISmartSecretOptions)
| Option | Type | Default | Description |
|---|---|---|---|
service |
string |
'smartsecret' |
Namespace for secret isolation |
vaultPath |
string |
~/.config/smartsecret/vault.json |
Path to the encrypted vault file (file backend only) |
Methods
| Method | Signature | Description |
|---|---|---|
setSecret |
(account: string, secret: string) => Promise<void> |
Store or overwrite a secret |
getSecret |
(account: string) => Promise<string | null> |
Retrieve a secret, or null if not found |
deleteSecret |
(account: string) => Promise<boolean> |
Delete a secret; returns true if it existed |
listAccounts |
() => Promise<string[]> |
List all account names for the configured service |
getBackendType |
() => Promise<TBackendType> |
Returns the active backend identifier |
SmartSecretKeyring
Create keyrings through the asynchronous factory. The constructor is not public.
SmartSecretKeyring.create(config: ISmartSecretKeyringConfig): Promise<SmartSecretKeyring>
| Method | Signature | Description |
|---|---|---|
encryptEnvelope |
(options: IEncryptSmartSecretEnvelopeOptions) => Promise<ISmartSecretEnvelopeV1> |
Encrypt with a random DEK and wrap it under the active KEK |
decryptEnvelope |
(options: IDecryptSmartSecretEnvelopeOptions) => Promise<Uint8Array> |
Strictly parse, authenticate, and decrypt an envelope |
rewrapEnvelope |
(options: IRewrapSmartSecretEnvelopeOptions) => Promise<ISmartSecretEnvelopeV1> |
Rewrap only the DEK under the active KEK |
recoverySelfTest |
(version: number) => Promise<ISmartSecretRecoverySelfTestResult> |
Wrap and unwrap a synthetic random DEK for an active or retired version |
inspect |
() => ISmartSecretKeyringInspection |
Return cloned, value-free metadata without credential paths or key bytes |
destroy |
() => void |
Mark the instance unusable and best-effort wipe retained key arrays |
All keyring failures are SmartSecretKeyringError instances with a stable TSmartSecretKeyringErrorCode. Their message, JSON representation, Node.js inspection output, and stack contain only the code and fixed package boilerplate. Filesystem and SmartCrypto causes are not retained.
SmartSecretKernelStore
Create kernel stores through the asynchronous factory. The constructor is not public.
SmartSecretKernelStore.create(
options: ISmartSecretKernelStoreOptions,
): Promise<SmartSecretKernelStore>
| Method | Signature | Description |
|---|---|---|
getEntry |
(account: string, options?) => Promise<Uint8Array | null> |
Read a copied raw value or return null when absent |
setEntry |
(account: string, value: Uint8Array, options?) => Promise<void> |
Create or replace one framed entry |
deleteEntry |
(account: string, options?) => Promise<boolean> |
Delete an entry and report whether it existed |
readDevIdpV1Legacy |
(account: string, options?) => Promise<IDevIdpV1LegacyRead | null> |
Read one verified, opaque keyutils legacy value |
deleteDevIdpV1Legacy |
(receipt, options?) => Promise<'deleted' | 'alreadyAbsent'> |
Revalidate and consume one legacy receipt |
close |
() => Promise<void> |
Wait for reserved operations and confirm worker termination |
Operation options contain optional timeoutMs and signal: AbortSignal properties. Failures are code-only SmartSecretKernelStoreError instances. Stable codes distinguish invalid input, unsupported runtime, unavailable kernel/worker/mutex resources, root conflicts, corrupt entries, changed migration sources, pre-dispatch aborts/timeouts, unknown mutation outcomes, poisoned/closed lifecycle state, and worker integrity failures. Secret values and underlying causes are never retained on these errors.
SmartSecretSealedFileStore
Create a sealed store with a caller-owned kernel store and a dedicated absolute private directory:
SmartSecretSealedFileStore.create(
options: ISmartSecretSealedFileStoreOptions,
): Promise<SmartSecretSealedFileStore>
This API is available only on Linux. directoryPath must be an absolute normalized path. The exported options and kernel-store contract are:
interface ISmartSecretSealedFileKernelStore {
readonly service: string;
getEntry(
account: string,
options?: ISmartSecretKernelOperationOptions,
): Promise<Uint8Array | null>;
setEntry(
account: string,
value: Uint8Array,
options?: ISmartSecretKernelOperationOptions,
): Promise<void>;
deleteEntry(
account: string,
options?: ISmartSecretKernelOperationOptions,
): Promise<boolean>;
}
interface ISmartSecretSealedFileStoreOptions {
kernelStore: ISmartSecretSealedFileKernelStore;
storeId: string;
directoryPath: string;
}
Instances expose readonly service, storeId, and directoryPath properties. The exported smartSecretSealedFileMaximumEntryBytes constant is 524288.
| Method | Signature | Description |
|---|---|---|
create |
(options) => Promise<SmartSecretSealedFileStore> |
Open or initialize a store without replacing missing key material |
reset |
(options) => Promise<SmartSecretSealedFileStore> |
Destructively discard store-owned ciphertext and create a new empty store |
getEntry |
(account: string) => Promise<Uint8Array | null> |
Authenticate and decrypt a copied value or return null when absent |
setEntry |
(account: string, value: Uint8Array) => Promise<void> |
Atomically encrypt and replace an entry up to 524,288 bytes |
deleteEntry |
(account: string) => Promise<boolean> |
Delete an encrypted entry and report whether it existed |
close |
() => Promise<void> |
Drain admitted operations and best-effort wipe the retained master-key copy |
Failures are code-only SmartSecretSealedFileStoreError instances. TSmartSecretSealedFileStoreErrorCode is the exact union AUTHENTICATION_FAILED | ENVELOPE_INVALID | FILESYSTEM_FAILED | INVALID_ARGUMENT | KERNEL_UNAVAILABLE | MASTER_KEY_CONFLICT | MASTER_KEY_UNAVAILABLE | MUTATION_OUTCOME_UNKNOWN | MUTEX_FAILED | OPERATION_LIMIT_REACHED | SIZE_LIMIT_EXCEEDED | STORE_CLOSED. ISmartSecretSealedFileStoreErrorJson describes the stable { name, code, message } JSON and inspection shape. The caller retains ownership of the kernel store and must close it separately.
Types
type TBackendType = 'macos-keychain' | 'linux-secret-service' | 'file-encrypted';
interface ISmartSecretOptions {
service?: string;
vaultPath?: string;
}
interface ISecretBackend {
readonly backendType: TBackendType;
isAvailable(): Promise<boolean>;
setSecret(account: string, secret: string): Promise<void>;
getSecret(account: string): Promise<string | null>;
deleteSecret(account: string): Promise<boolean>;
listAccounts(): Promise<string[]>;
}
Backend Classes
Each backend implements ISecretBackend and can be used directly if needed:
MacosKeychainBackend-- macOS Keychain via thesecurityCLILinuxSecretServiceBackend-- Linux Secret Service viasecret-toolFileEncryptedBackend-- AES-256-GCM encrypted JSON vault file
Backends
SmartSecret tries each backend in order and uses the first one that reports itself as available.
macOS Keychain (macos-keychain)
Used automatically on macOS when the security command-line tool is present (ships with macOS by default). Secrets are stored as generic password items in the user's default keychain. The service option maps to the keychain service name, and the account parameter maps to the keychain account name.
Linux Secret Service (linux-secret-service)
Used automatically on Linux when secret-tool is installed. This integrates with GNOME Keyring, KDE Wallet, or any other provider that implements the freedesktop.org Secret Service D-Bus API. Install the tool on Debian/Ubuntu with:
sudo apt install libsecret-tools
Secrets are stored with service and account as lookup attributes.
Encrypted File (file-encrypted)
The universal fallback that works on all platforms. Secrets are encrypted with AES-256-GCM and stored in a JSON vault file. A random 32-byte key is generated on first use and stored alongside the vault at ~/.config/smartsecret/.keyfile (with 0600 permissions). The encryption key is derived from the keyfile using PBKDF2 with 100,000 iterations of SHA-512, salted with the service name.
Vault writes are atomic (write to a temporary file, then rename) to prevent corruption. Both the keyfile and the vault file are created with restrictive file permissions.
File locations (defaults):
| File | Path |
|---|---|
| Vault | ~/.config/smartsecret/vault.json |
| Keyfile | ~/.config/smartsecret/.keyfile |
Both paths can be influenced by providing a custom vaultPath in the constructor options. The keyfile is always stored in the same directory as the vault.
License and Legal Information
This repository contains open-source code licensed under the MIT License. A copy of the license can be found in the repository license file.
The packaged Linux kernel worker contains statically linked third-party Rust components. Their copyright and license notices are reproduced in third-party-notices.md.
Please note: The MIT License does not grant permission to use the trade names, trademarks, service marks, or product names of the project, except as required for reasonable and customary use in describing the origin of the work and reproducing the content of the NOTICE file.
Trademarks
This project is owned and maintained by Task Venture Capital GmbH. The names and logos associated with Task Venture Capital GmbH and any related products or services are trademarks of Task Venture Capital GmbH or third parties, and are not included within the scope of the MIT license granted herein.
Use of these trademarks must comply with Task Venture Capital GmbH's Trademark Guidelines or the guidelines of the respective third-party owners, and any usage must be approved in writing. Third-party trademarks used herein are the property of their respective owners and used only in a descriptive manner, e.g. for an implementation of an API or similar.
Company Information
Task Venture Capital GmbH
Registered at District Court Bremen HRB 35230 HB, Germany
For any legal inquiries or further information, please contact us via email at hello@task.vc.
By using this repository, you acknowledge that you have read this section, agree to comply with its terms, and understand that the licensing of the code does not imply endorsement by Task Venture Capital GmbH of any derivative works.