@zamatica/secrets
v0.1.0
Published
Pluggable secrets-provider interface + reference obfuscated-file impl + NestJS adapter. Core types are framework-agnostic; ship your own provider for real secret stores (systemd-creds, CyberArk, Broadcom PAM, k8s Secrets, etc.).
Readme
@zamatica/secrets
Pluggable SecretsProvider interface, a reference obfuscated-file implementation, and a NestJS adapter. Drop in your real provider (systemd-creds, CyberArk, Broadcom PAM, k8s mounted Secret, etc.) by satisfying the same interface.
Subpaths
@zamatica/secrets— theSecretsProviderinterface + theSECRETS_PROVIDERDI token string. No framework deps.@zamatica/secrets/file—createFileSecretsProvider({ baseDir })and thescrambleNotEncrypt/unscrambleprimitives. Disk-only.@zamatica/secrets/nestjs—SecretsModule.forRoot({ provider }).
Interface
export interface SecretsProvider {
get(key: string): Promise<string | undefined>;
set?(key: string, value: string): Promise<void>;
list?(prefix?: string): Promise<readonly string[]>;
readonly capabilities: { writable: boolean; enumerable: boolean };
}set and list are optional — read-only providers (CyberArk-style) leave them off and advertise capabilities.writable = false / capabilities.enumerable = false. Consumers check capabilities before invoking.
The file impl is OBFUSCATION, not encryption
Per the template's "super simple obfuscated file store" decision: secrets are XOR-scrambled with a constant key (no per-install secret) and stored as Base64 files under baseDir. The function is deliberately named scrambleNotEncrypt. Anyone with disk access can recover. Suitable as a deterrent against casual eyeballing and as a development default. Production deployments swap providers.
Per-file layout (vs a single secrets.json bag):
- Concurrent writes from daemon + admin CLI need no lock.
list()isreaddir— no parsing.- Per-secret delete is
rm $baseDir/$key— no rewrite that could corrupt other secrets on crash. - Per-file mode is
0600; baseDir is0700. Writes are atomic via sibling.tmp+ rename.
Keys are validated: no path separators, no . / .. / empty / dotfile names. A daemon's secrets.get(userInput) cannot traverse out of baseDir.
NestJS wiring
import { SecretsModule } from '@zamatica/secrets/nestjs';
import { createFileSecretsProvider } from '@zamatica/secrets/file';
@Module({
imports: [
SecretsModule.forRoot({
provider: createFileSecretsProvider({ baseDir: '/etc/mtz/secrets' }),
}),
],
})
export class AppModule {}Consumers @Inject(SECRETS_PROVIDER) private secrets: SecretsProvider. The module is global: true so feature modules don't need to re-import.
Swapping in a real provider
// e.g. systemd-creds via CREDENTIALS_DIRECTORY (systemd v250+)
import { readFile } from 'node:fs/promises';
import { join } from 'node:path';
function createSystemdCredsProvider(): SecretsProvider {
const dir = process.env['CREDENTIALS_DIRECTORY'];
if (dir === undefined) throw new Error('not running under systemd LoadCredential');
return {
capabilities: { writable: false, enumerable: true },
async get(key) {
try {
return (await readFile(join(dir, key), 'utf8')).trim();
} catch { return undefined; }
},
async list(prefix) {
const fs = await import('node:fs/promises');
const entries = await fs.readdir(dir);
return prefix ? entries.filter(e => e.startsWith(prefix)) : entries;
},
};
}The consumer's SecretsModule.forRoot doesn't care which provider you pass — same DI token, same call sites.
Status
v0.x. The interface is stable; the file impl is intentionally simple. If a second descendant needs a real provider, harvest the implementation back into this lib as @zamatica/secrets/<provider-name> for reuse.
