just-secrets 0.0.1

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Vercel, Inc.
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,135 @@
1
+ # just-secrets
2
+
3
+ Native OS secret storage for Node.js 24+. Zero dependencies, native addons, or install scripts.
4
+
5
+ ```sh
6
+ npm install just-secrets
7
+ ```
8
+
9
+ ```js
10
+ import { secrets } from 'just-secrets';
11
+
12
+ await secrets.set({
13
+ service: 'com.example.cli',
14
+ name: 'api-token',
15
+ value: 'your-api-token',
16
+ });
17
+
18
+ const token = await secrets.get({ service: 'com.example.cli', name: 'api-token' });
19
+ const deleted = await secrets.delete({ service: 'com.example.cli', name: 'api-token' });
20
+ ```
21
+
22
+ Node 24 also supports `const { secrets } = require('just-secrets')`. TypeScript declarations ship with the package. There is no build step.
23
+
24
+ ## API
25
+
26
+ Every operation is asynchronous and returns a Promise. Object and positional forms are interchangeable:
27
+
28
+ ```js
29
+ await secrets.set({ service, name, value }); // Promise<void>
30
+ await secrets.set(service, name, value);
31
+
32
+ await secrets.get({ service, name }); // Promise<string | null>
33
+ await secrets.get(service, name);
34
+
35
+ await secrets.delete({ service, name }); // Promise<boolean>
36
+ await secrets.delete(service, name);
37
+ ```
38
+
39
+ - `set` creates a credential or replaces its value.
40
+ - `get` returns `null` when the credential is absent.
41
+ - `delete` returns `true` on deletion, or `false` when absent.
42
+ - Unavailable stores, denied access, unexpected responses, and timeouts reject. They are not treated as missing credentials.
43
+ - Empty values, whitespace, newlines, NUL, and Unicode are preserved exactly. Service and name are case-sensitive and are not normalized.
44
+ - Service and name must be nonempty strings, each at most 256 UTF-8 bytes. Values must be strings of at most 2,560 UTF-8 bytes. Unpaired Unicode surrogates are rejected. Invalid arguments reject with `TypeError` or `RangeError`.
45
+
46
+ macOS also has a 4,096-byte interactive-command buffer. A combination of long identifiers and a maximum-size value can exceed it; just-secrets rejects before starting the operation. Smaller identifiers or values are then required.
47
+
48
+ just-secrets uses its own namespaced identifiers and storage representation; it does not discover or migrate credentials written by other libraries.
49
+
50
+ ## Platform requirements
51
+
52
+ | OS | Native store | Bridge | Requirements |
53
+ | --- | --- | --- | --- |
54
+ | macOS | Login Keychain | `/usr/bin/security` | An accessible login keychain; macOS may prompt for access or unlock |
55
+ | Linux | Secret Service, such as GNOME Keyring or compatible KWallet | `/usr/bin/secret-tool` | libsecret tools, a session D-Bus, and a running Secret Service provider |
56
+ | Windows | Credential Manager | Windows PowerShell and the Windows Credentials API | Windows PowerShell 5.1 with `Add-Type` permitted and a credential-capable user logon session |
57
+
58
+ The JavaScript package contains no native binaries. Windows uses a bundled PowerShell script with an embedded C# P/Invoke bridge, compiled by the OS's `Add-Type`. This requires no downloaded module or developer toolchain, but is not JavaScript-only execution. Restricted PowerShell environments may block it; just-secrets does not bypass those restrictions.
59
+
60
+ On Debian/Ubuntu, install Linux prerequisites with:
61
+
62
+ ```sh
63
+ sudo apt-get install libsecret-tools gnome-keyring
64
+ ```
65
+
66
+ Installing the tools is not enough: a session bus and unlocked credential service must also be available. Headless containers, SSH sessions, and WSL often lack these. WSL uses the Linux backend; just-secrets does not automatically forward secrets to Windows. Unsupported operating systems reject without starting a subprocess.
67
+
68
+ Windows credentials use `CRED_PERSIST_ENTERPRISE`: credentials persist for the user and may roam when supported by the account configuration.
69
+
70
+ ## Security model
71
+
72
+ Secrets are encrypted at rest by the OS credential store. just-secrets never writes a plaintext vault, generates a file-encryption key, or falls back to weaker storage. It invokes known OS executables without a shell and sends secret input over pipes, not command-line arguments or environment variables. Internal encodings preserve exact strings; they are not encryption.
73
+
74
+ Each subprocess has a 60-second timeout and a combined 1 MiB output limit. Child-process errors and output are not attached to thrown errors. Values are not cached between calls or logged by the library.
75
+
76
+ This does not isolate secrets from malicious code running as the same OS user. On macOS, Keychain access is associated with `/usr/bin/security`, not a unique just-secrets application identity. Other programs invoking that tool can access items permitted to it. See [GitHub CLI's discussion of this tradeoff](https://github.com/cli/cli/blob/trunk/docs/macos-keyring.md). Windows and Linux access boundaries also depend on the user's session and store configuration.
77
+
78
+ JavaScript and PowerShell strings cannot be reliably zeroized. Secrets exist in process memory while in use and may be exposed through debuggers, memory dumps, or compromised processes. Node copies some data internally; clearing individual buffers is not a complete memory-erasure guarantee. See [SECURITY.md](SECURITY.md).
79
+
80
+ Linux explicitly checks and unlocks matching items before reading or deleting, rather than mistaking a locked item for absence. These multi-command operations are not transactional. Concurrent changes can cause rejection, and a timed-out or failed mutation may already have taken effect. Do not automatically retry mutations without checking the result.
81
+
82
+ ## Handling unavailable storage
83
+
84
+ Storage failures never silently fall back to files. Applications can offer session-only credentials, ask the user to unlock their keychain, or explicitly accept an environment variable for CI:
85
+
86
+ ```js
87
+ const token = process.env.MY_API_TOKEN ?? await secrets.get('com.example.cli', 'api-token');
88
+ ```
89
+
90
+ Do not log secret values or arbitrary upstream error objects. just-secrets's operational errors carry a `code`:
91
+
92
+ | Code | Meaning |
93
+ | --- | --- |
94
+ | `ERR_SECRETS_UNSUPPORTED` | Unsupported operating system |
95
+ | `ERR_SECRETS_UNAVAILABLE` | Required executable or credential session unavailable |
96
+ | `ERR_SECRETS_ACCESS_DENIED` | Access denied where the platform exposes a distinct status |
97
+ | `ERR_SECRETS_TIMEOUT` | Credential operation exceeded its time limit |
98
+ | `ERR_SECRETS_OUTPUT_LIMIT` | Unexpectedly large credential-tool output |
99
+ | `ERR_SECRETS_STORE` | Other store failure or unexpected response |
100
+
101
+ Not every backend can distinguish locked, denied, and unavailable states; these can surface as `ERR_SECRETS_STORE`. Callers should provide an unlock/setup recovery path rather than interpreting this code as absence.
102
+
103
+ ## Development
104
+
105
+ Only Node.js 24+ and npm are required for development. There are no runtime or development npm dependencies.
106
+
107
+ ```sh
108
+ npm ci
109
+ npm test # node:test; injected adapters and real subprocess transport tests
110
+ npm run test:coverage # Node's built-in coverage
111
+ npm run test:integration # real native credential-store lifecycle
112
+ npm run test:package # pack, offline install, ESM/CommonJS import checks
113
+ ```
114
+
115
+ Integration tests use a disposable keychain on macOS and UUID-namespaced credentials on Linux/Windows, with cleanup. They cover create, read, overwrite, delete, missing entries, Unicode, empty values, case-sensitive identity, and persistence across Node processes. They do not prove persistence across reboot.
116
+
117
+ The GitHub Actions matrix runs Node 24 on Linux x64/ARM64, macOS Intel/Apple Silicon, and Windows x64. Linux CI starts an isolated GNOME Keyring session. Native Windows tests require the Windows runner; macOS and Linux mocks alone cannot validate the PowerShell bridge.
118
+
119
+ ## Publishing
120
+
121
+ Repository: [vercel-labs/just-secrets](https://github.com/vercel-labs/just-secrets).
122
+
123
+ After the full CI matrix passes, review the version and package contents, then publish using an npm account authorized for `just-secrets`:
124
+
125
+ ```sh
126
+ npm run test:package
127
+ npm pack --dry-run
128
+ npm publish --access public
129
+ ```
130
+
131
+ Publishing credentials and npm package ownership must be configured separately. This repository does not automatically publish on push.
132
+
133
+ ## License
134
+
135
+ MIT
package/SECURITY.md ADDED
@@ -0,0 +1,21 @@
1
+ # Security
2
+
3
+ ## Reporting vulnerabilities
4
+
5
+ Do not post credentials or exploit details in a public issue. Report security vulnerabilities privately to [Vercel Security](https://vercel.com/security).
6
+
7
+ ## Boundaries
8
+
9
+ - just-secrets stores credentials in the current user's native OS store. It is not a sandbox, secret broker, or defense against code running as that user.
10
+ - On macOS, the trusted Keychain client is `/usr/bin/security`, not the JavaScript package or the calling application.
11
+ - Linux requires a working Secret Service session. Windows requires a credential-capable logon session and permission to run the bundled PowerShell bridge.
12
+ - Secrets travel through stdin/stdout pipes. They never enter subprocess arguments, generated script source, or library logs. Identifiers are not secrets and can appear in process arguments and native-store metadata.
13
+ - There is no automatic plaintext, encrypted-file, memory-cache, or alternate-executable fallback.
14
+ - The library bounds subprocess execution and output. It does not return captured stderr, stdout, or native exceptions in operational errors.
15
+ - JavaScript and managed-runtime strings cannot be reliably erased. Debugging tools, crash dumps, OS audit tooling, or same-user malware may observe values while in use. Buffer cleanup does not guarantee complete zeroization.
16
+ - The application remains responsible for avoiding secret disclosure after retrieval, choosing appropriate service/name scopes, controlling subprocess inheritance, and securing its dependencies and runtime.
17
+ - Operations can fail after a mutation commits. Linux's search/unlock/read/delete sequence is not atomic, and timeouts do not imply rollback.
18
+
19
+ ## Review and tests
20
+
21
+ Changes to subprocess invocation, encoding, error handling, or native bridges require regression tests. Run the native integration suite on every supported OS before release; mocked tests cannot validate OS access-control behavior. The package has no npm dependencies or install-time downloads.
package/package.json ADDED
@@ -0,0 +1,52 @@
1
+ {
2
+ "name": "just-secrets",
3
+ "version": "0.0.1",
4
+ "description": "Native OS secret storage for Node.js. Zero dependencies.",
5
+ "type": "module",
6
+ "main": "./src/index.js",
7
+ "types": "./src/index.d.ts",
8
+ "exports": {
9
+ ".": {
10
+ "types": "./src/index.d.ts",
11
+ "default": "./src/index.js"
12
+ },
13
+ "./package.json": "./package.json"
14
+ },
15
+ "files": [
16
+ "src",
17
+ "README.md",
18
+ "LICENSE",
19
+ "SECURITY.md"
20
+ ],
21
+ "sideEffects": false,
22
+ "engines": {
23
+ "node": ">=24"
24
+ },
25
+ "scripts": {
26
+ "test": "node --test test/*.test.js",
27
+ "test:integration": "node --test --test-concurrency=1 test/native.integration.js",
28
+ "test:package": "node --test test/package.integration.js",
29
+ "test:coverage": "node --test --experimental-test-coverage test/*.test.js",
30
+ "prepack": "npm test"
31
+ },
32
+ "repository": {
33
+ "type": "git",
34
+ "url": "git+https://github.com/vercel-labs/just-secrets.git"
35
+ },
36
+ "homepage": "https://github.com/vercel-labs/just-secrets#readme",
37
+ "bugs": {
38
+ "url": "https://github.com/vercel-labs/just-secrets/issues"
39
+ },
40
+ "license": "MIT",
41
+ "keywords": [
42
+ "secrets",
43
+ "keychain",
44
+ "keyring",
45
+ "credentials",
46
+ "keytar",
47
+ "nodejs"
48
+ ],
49
+ "publishConfig": {
50
+ "access": "public"
51
+ }
52
+ }
package/src/command.js ADDED
@@ -0,0 +1,84 @@
1
+ import { spawn } from 'node:child_process';
2
+ import { storeError } from './errors.js';
3
+
4
+ // Deliberately private: public callers cannot choose an executable or disable limits.
5
+ export function runCommand(executable, args, {
6
+ input = '', timeout = 60_000, maxOutput = 1024 * 1024,
7
+ } = {}) {
8
+ return new Promise((resolve, reject) => {
9
+ let child;
10
+ try {
11
+ child = spawn(executable, args, {
12
+ shell: false,
13
+ windowsHide: true,
14
+ stdio: ['pipe', 'pipe', 'pipe'],
15
+ // Do not load PowerShell profiles or permit cwd/PATH-based backend selection.
16
+ cwd: undefined,
17
+ });
18
+ } catch {
19
+ reject(storeError('Could not start the OS credential tool.', 'ERR_SECRETS_UNAVAILABLE'));
20
+ return;
21
+ }
22
+ const stdout = [], stderr = [];
23
+ let size = 0, failure, inputFailure, settled = false;
24
+ const finish = (error, result) => {
25
+ if (settled) return;
26
+ settled = true;
27
+ clearTimeout(timer);
28
+ for (const chunk of [...stdout, ...stderr]) chunk.fill(0);
29
+ stdout.length = stderr.length = 0;
30
+ if (error) reject(error);
31
+ else resolve(result);
32
+ };
33
+ const terminate = (error) => {
34
+ failure ??= error;
35
+ child.kill('SIGKILL');
36
+ child.stdin.destroy();
37
+ child.stdout.destroy();
38
+ child.stderr.destroy();
39
+ finish(failure);
40
+ };
41
+ const timer = setTimeout(() => terminate(storeError(
42
+ 'The OS credential tool timed out. Unlock your credential store and retry.',
43
+ 'ERR_SECRETS_TIMEOUT',
44
+ )), timeout);
45
+ for (const [stream, chunks] of [[child.stdout, stdout], [child.stderr, stderr]]) {
46
+ stream.on('data', (chunk) => {
47
+ if (settled) { chunk.fill(0); return; }
48
+ size += chunk.length;
49
+ if (size > maxOutput) {
50
+ chunk.fill(0);
51
+ terminate(storeError('The OS credential tool exceeded its output limit.', 'ERR_SECRETS_OUTPUT_LIMIT'));
52
+ } else chunks.push(chunk);
53
+ });
54
+ stream.on('error', () => terminate(storeError('Could not read the OS credential tool response.')));
55
+ }
56
+ child.on('error', (error) => {
57
+ const code = error.code === 'EACCES' || error.code === 'EPERM'
58
+ ? 'ERR_SECRETS_ACCESS_DENIED' : 'ERR_SECRETS_UNAVAILABLE';
59
+ finish(storeError('Could not start the OS credential tool. Check its installation and permissions.', code));
60
+ });
61
+ // Pipe error codes differ across platforms. Let the child report its own failure;
62
+ // a successful exit must not hide undelivered input. The timer still bounds the wait.
63
+ child.stdin.on('error', () => {
64
+ inputFailure = storeError('Could not send input to the OS credential tool.');
65
+ });
66
+ child.on('close', (code, signal) => {
67
+ if (settled) return;
68
+ if (signal !== null || !Number.isInteger(code)) {
69
+ finish(storeError('The OS credential tool terminated unexpectedly.'));
70
+ return;
71
+ }
72
+ if (code === 0 && inputFailure) {
73
+ finish(inputFailure);
74
+ return;
75
+ }
76
+ const out = Buffer.concat(stdout), err = Buffer.concat(stderr);
77
+ const result = { code, stdout: out.toString('utf8'), stderr: err.toString('utf8') };
78
+ out.fill(0);
79
+ err.fill(0);
80
+ finish(undefined, result);
81
+ });
82
+ child.stdin.end(input);
83
+ });
84
+ }
package/src/errors.js ADDED
@@ -0,0 +1,3 @@
1
+ export function storeError(message, code = 'ERR_SECRETS_STORE') {
2
+ return Object.assign(new Error(message), { code });
3
+ }
package/src/index.d.ts ADDED
@@ -0,0 +1,20 @@
1
+ /** Identifies a credential within a service. */
2
+ export interface SecretsOptions {
3
+ service: string;
4
+ name: string;
5
+ }
6
+
7
+ export interface Secrets {
8
+ /** Returns the stored secret, or null when it does not exist. */
9
+ get(options: SecretsOptions): Promise<string | null>;
10
+ get(service: string, name: string): Promise<string | null>;
11
+ /** Creates or replaces a secret in the native credential store. */
12
+ set(options: SecretsOptions & { value: string }): Promise<void>;
13
+ set(service: string, name: string, value: string): Promise<void>;
14
+ /** Returns true when deleted, or false when it does not exist. */
15
+ delete(options: SecretsOptions): Promise<boolean>;
16
+ delete(service: string, name: string): Promise<boolean>;
17
+ }
18
+
19
+ /** Native secret storage for Node.js 24+, using OS-provided credential tools. */
20
+ export declare const secrets: Secrets;
package/src/index.js ADDED
@@ -0,0 +1,3 @@
1
+ import { createSecrets } from './secrets.js';
2
+
3
+ export const secrets = createSecrets();
package/src/linux.js ADDED
@@ -0,0 +1,97 @@
1
+ import { runCommand } from './command.js';
2
+ import { storeError } from './errors.js';
3
+
4
+ const executable = '/usr/bin/secret-tool';
5
+ const message = 'Linux credential operation failed; ensure /usr/bin/secret-tool and an unlocked Secret Service session are available';
6
+ const prefix = 'secrets:v1:';
7
+ const transportCodes = new Set([
8
+ 'ERR_SECRETS_UNAVAILABLE', 'ERR_SECRETS_TIMEOUT', 'ERR_SECRETS_OUTPUT_LIMIT',
9
+ 'ERR_SECRETS_STORE', 'ERR_SECRETS_ACCESS_DENIED',
10
+ ]);
11
+
12
+ // Storage format (not encryption): canonical base64 of UTF-8 after secrets:v1:.
13
+ // Encode identifiers too: argv and libsecret attributes cannot represent NUL.
14
+ function encode(value) {
15
+ return prefix + Buffer.from(value, 'utf8').toString('base64');
16
+ }
17
+
18
+ function decode(output) {
19
+ if (!output.startsWith(prefix) || output.length > prefix.length + 3416) {
20
+ throw storeError(message);
21
+ }
22
+ const encoded = output.slice(prefix.length);
23
+ const bytes = Buffer.from(encoded, 'base64');
24
+ // Buffer's base64 and UTF-8 decoders are permissive; require lossless canonical forms.
25
+ const value = bytes.toString('utf8');
26
+ if (bytes.length > 2560 || bytes.toString('base64') !== encoded
27
+ || !Buffer.from(value, 'utf8').equals(bytes)) {
28
+ throw storeError(message);
29
+ }
30
+ return value;
31
+ }
32
+
33
+ export function createLinuxBackend({ run = runCommand } = {}) {
34
+ async function request(operation, service, name, value) {
35
+ const attributes = ['application', 'secrets', 'service', encode(service), 'name', encode(name)];
36
+ let result;
37
+ try {
38
+ result = await run(executable, [
39
+ operation,
40
+ ...(operation === 'store' ? ['--label=secrets'] : operation === 'search' ? ['--all', '--unlock'] : []),
41
+ '--', ...attributes,
42
+ ], { input: operation === 'store' ? encode(value) : '' });
43
+ } catch (error) {
44
+ const code = transportCodes.has(error?.code) ? error.code
45
+ : error?.code === 'ENOENT' ? 'ERR_SECRETS_UNAVAILABLE'
46
+ : error?.code === 'EACCES' || error?.code === 'EPERM' ? 'ERR_SECRETS_ACCESS_DENIED'
47
+ : 'ERR_SECRETS_STORE';
48
+ throw storeError(message, code);
49
+ }
50
+ if (!result || !Number.isInteger(result.code)
51
+ || typeof result.stdout !== 'string' || typeof result.stderr !== 'string') {
52
+ throw storeError(message);
53
+ }
54
+ if (result.code !== 0) throw storeError(message);
55
+ if (operation === 'search') {
56
+ // secret-tool search prints attributes to stderr even on success. Accept
57
+ // only our exact encoded attributes; retrieval/unlock diagnostics fail closed.
58
+ const expected = new Set([0, 2, 4].map(i => `attribute.${attributes[i]} = ${attributes[i + 1]}\n`));
59
+ const lines = result.stderr.match(/[^\n]*\n|[^\n]+$/g) ?? [];
60
+ if (lines.some(line => !expected.has(line))) throw storeError(message);
61
+ if (result.stdout === '' && result.stderr === '') return false;
62
+ // Search retrieves secrets into the bounded pipe, never logs them. Validate
63
+ // complete records so missing secrets (locked items) cannot look successful.
64
+ const record = /\[[^\r\n\]]+\]\nlabel = [^\r\n]*\nsecret = (secrets:v1:[A-Za-z0-9+/=]*)\ncreated = [^\r\n]*\nmodified = [^\r\n]*\n(?:schema = [^\r\n]*\n)?/gy;
65
+ let end = 0;
66
+ let match;
67
+ while ((match = record.exec(result.stdout)) !== null) {
68
+ decode(match[1]);
69
+ end = record.lastIndex;
70
+ }
71
+ if (end === 0 || end !== result.stdout.length) throw storeError(message);
72
+ return true;
73
+ }
74
+ // Never trim: even whitespace stderr is a diagnostic, not absence.
75
+ if (result.stderr !== '' || (operation !== 'lookup' && result.stdout !== '')) throw storeError(message);
76
+ return operation === 'lookup' ? decode(result.stdout) : true;
77
+ }
78
+
79
+ return {
80
+ async get(service, name) {
81
+ if (!await request('search', service, name)) return null;
82
+ return request('lookup', service, name);
83
+ },
84
+ async set(service, name, value) {
85
+ await request('store', service, name, value);
86
+ },
87
+ async delete(service, name) {
88
+ // clear ignores locked matches. Only an empty all-items search proves absence.
89
+ // These calls are not atomic: relocking/removal can cause an error after
90
+ // mutation, and a concurrent writer can recreate an item after the final check.
91
+ if (!await request('search', service, name)) return false;
92
+ await request('clear', service, name);
93
+ if (await request('search', service, name)) throw storeError(message);
94
+ return true;
95
+ },
96
+ };
97
+ }
package/src/macos.js ADDED
@@ -0,0 +1,65 @@
1
+ import { runCommand } from './command.js';
2
+ import { storeError } from './errors.js';
3
+
4
+ const executable = '/usr/bin/security';
5
+ const prefix = 'secrets:v1:';
6
+ const encode = (value) => prefix + Buffer.from(value, 'utf8').toString('base64');
7
+ // security -i has its own parser, not a shell. Never allow a second input line.
8
+ const quote = (value) => '"' + value.replaceAll('\\', '\\\\').replaceAll('"', '\\"') + '"';
9
+ const message = 'macOS Keychain operation failed. Check Keychain access and unlock your login keychain.';
10
+
11
+ export function createMacosBackend({ run = runCommand, keychain = 'login.keychain-db' } = {}) {
12
+ if (/[\r\n\0]/u.test(keychain)) throw new TypeError('Invalid keychain path.');
13
+ const attributes = (service, name) => ['-s', encode(service), '-a', encode(name)];
14
+ function check(result, absent = false) {
15
+ if (absent && result.code === 44) return false;
16
+ if (result.code !== 0) {
17
+ const code = result.code === 36 || result.code === 128
18
+ ? 'ERR_SECRETS_ACCESS_DENIED' : 'ERR_SECRETS_STORE';
19
+ throw storeError(message, code);
20
+ }
21
+ return true;
22
+ }
23
+ // security can report errSecItemNotFound even when the keychain itself is missing.
24
+ const ready = async () => check(await run(executable, ['-q', 'show-keychain-info', keychain]));
25
+ return {
26
+ async get(service, name) {
27
+ await ready();
28
+ const result = await run(executable, [
29
+ '-q', 'find-generic-password', ...attributes(service, name), '-w', keychain,
30
+ ]);
31
+ if (!check(result, true)) return null;
32
+ const output = result.stdout.replace(/\n$/u, '');
33
+ if (!output.startsWith(prefix)) throw storeError(message);
34
+ const encoded = output.slice(prefix.length);
35
+ const bytes = Buffer.from(encoded, 'base64');
36
+ const value = bytes.toString('utf8');
37
+ if (bytes.length > 2560 || bytes.toString('base64') !== encoded
38
+ || !Buffer.from(value, 'utf8').equals(bytes)) throw storeError(message);
39
+ return value;
40
+ },
41
+ async set(service, name, value) {
42
+ const input = [
43
+ 'add-generic-password', '-U', ...attributes(service, name),
44
+ '-w', encode(value), keychain,
45
+ ].map(quote).join(' ') + '\n';
46
+ // Apple's interactive reader uses a fixed 4096-byte buffer. Reject before spawning,
47
+ // rather than letting a truncated write succeed or become a second command.
48
+ if (Buffer.byteLength(input) >= 4096) {
49
+ throw new RangeError('The combined service, name, and value exceed the macOS command limit.');
50
+ }
51
+ await ready();
52
+ const result = await run(executable, ['-q', '-i'], { input });
53
+ check(result);
54
+ // Some security subcommands return -1, which interactive mode converts to success.
55
+ if (result.stderr !== '' || result.stdout !== '') throw storeError(message);
56
+ },
57
+ async delete(service, name) {
58
+ await ready();
59
+ const result = await run(executable, [
60
+ '-q', 'delete-generic-password', ...attributes(service, name), keychain,
61
+ ]);
62
+ return check(result, true);
63
+ },
64
+ };
65
+ }
package/src/secrets.js ADDED
@@ -0,0 +1,53 @@
1
+ import { createMacosBackend } from './macos.js';
2
+ import { createLinuxBackend } from './linux.js';
3
+ import { createWindowsBackend } from './windows.js';
4
+ import { storeError } from './errors.js';
5
+
6
+ function validate(value, field, limit) {
7
+ if (typeof value !== 'string') throw new TypeError(`${field} must be a string.`);
8
+ if (!value.isWellFormed()) throw new TypeError(`${field} must contain valid Unicode.`);
9
+ if (field !== 'value' && value.length === 0) throw new TypeError(`${field} must not be empty.`);
10
+ if (Buffer.byteLength(value, 'utf8') > limit) {
11
+ throw new RangeError(`${field} must not exceed ${limit} UTF-8 bytes.`);
12
+ }
13
+ return value;
14
+ }
15
+
16
+ function parse(args, write) {
17
+ const object = args[0] !== null && typeof args[0] === 'object' && !Array.isArray(args[0]);
18
+ if (args.length !== (object ? 1 : write ? 3 : 2)) {
19
+ throw new TypeError('Expected an options object or service, name' + (write ? ', value.' : '.'));
20
+ }
21
+ const options = object ? args[0] : { service: args[0], name: args[1], value: args[2] };
22
+ return [validate(options.service, 'service', 256), validate(options.name, 'name', 256),
23
+ ...(write ? [validate(options.value, 'value', 2560)] : [])];
24
+ }
25
+
26
+ function systemBackend() {
27
+ switch (process.platform) {
28
+ case 'darwin': return createMacosBackend();
29
+ case 'linux': return createLinuxBackend();
30
+ case 'win32': return createWindowsBackend();
31
+ default: throw storeError('Native secret storage is supported on macOS, Linux, and Windows.', 'ERR_SECRETS_UNSUPPORTED');
32
+ }
33
+ }
34
+
35
+ // Factories are internal test seams, not package exports. No secret values are cached.
36
+ export function createSecrets(resolveBackend = systemBackend) {
37
+ let backend;
38
+ const getBackend = () => backend ??= resolveBackend();
39
+ return Object.freeze({
40
+ async get(...args) {
41
+ const input = parse(args, false);
42
+ return getBackend().get(...input);
43
+ },
44
+ async set(...args) {
45
+ const input = parse(args, true);
46
+ await getBackend().set(...input);
47
+ },
48
+ async delete(...args) {
49
+ const input = parse(args, false);
50
+ return getBackend().delete(...input);
51
+ },
52
+ });
53
+ }
package/src/windows.js ADDED
@@ -0,0 +1,63 @@
1
+ import { readFile } from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import { runCommand } from './command.js';
4
+ import { storeError } from './errors.js';
5
+
6
+ const script = new URL('./windows.ps1', import.meta.url);
7
+ const message = 'Windows credential operation failed';
8
+ const transportCodes = new Set([
9
+ 'ERR_SECRETS_UNAVAILABLE', 'ERR_SECRETS_TIMEOUT', 'ERR_SECRETS_OUTPUT_LIMIT',
10
+ 'ERR_SECRETS_STORE', 'ERR_SECRETS_ACCESS_DENIED',
11
+ ]);
12
+
13
+ export function createWindowsBackend({ run = runCommand } = {}) {
14
+ let command;
15
+ async function request(operation, service, name, value) {
16
+ const root = process.env.SystemRoot ?? process.env.SYSTEMROOT;
17
+ if (typeof root !== 'string' || !/^[a-z]:\\/i.test(root) || root.includes('\0')) {
18
+ throw storeError('Windows PowerShell is unavailable', 'ERR_SECRETS_UNAVAILABLE');
19
+ }
20
+ const executable = path.win32.join(root, 'System32', 'WindowsPowerShell', 'v1.0', 'powershell.exe');
21
+ let result;
22
+ try {
23
+ // Fixed source only: -Command works under the default Restricted file policy.
24
+ // Credential data remains on stdin, never interpolated into source or argv.
25
+ command ??= readFile(script, 'utf8').then(source => Buffer.from(source, 'utf16le').toString('base64'));
26
+ result = await run(executable, [
27
+ '-NoLogo', '-NoProfile', '-NonInteractive', '-InputFormat', 'Text', '-OutputFormat', 'Text',
28
+ '-EncodedCommand', await command,
29
+ ], { input: JSON.stringify({ operation, service, name, ...(operation === 'set' ? { value } : {}) }) });
30
+ } catch (error) {
31
+ throw storeError(message, transportCodes.has(error?.code) ? error.code : 'ERR_SECRETS_STORE');
32
+ }
33
+ let response;
34
+ try {
35
+ if (!result || !Number.isInteger(result.code) || typeof result.stdout !== 'string') throw null;
36
+ response = JSON.parse(result.stdout);
37
+ if (!response || typeof response !== 'object' || Array.isArray(response) || Object.keys(response).length !== 1) throw null;
38
+ } catch {
39
+ throw storeError(message);
40
+ }
41
+ if (result.code !== 0) {
42
+ if (response.error === 1168 && operation !== 'set') return operation === 'get' ? null : false;
43
+ const code = response.error === 5 ? 'ERR_SECRETS_ACCESS_DENIED'
44
+ : response.error === 1312 || response.error === 50 || response.error === 'ERR_SECRETS_UNAVAILABLE'
45
+ ? 'ERR_SECRETS_UNAVAILABLE' : 'ERR_SECRETS_STORE';
46
+ throw storeError(message, code);
47
+ }
48
+ if (result.stderr !== '' || !Object.hasOwn(response, 'value')) throw storeError(message);
49
+ if (operation === 'get') {
50
+ if (response.value !== null && (typeof response.value !== 'string'
51
+ || !response.value.isWellFormed() || Buffer.byteLength(response.value, 'utf8') > 2560)) throw storeError(message);
52
+ return response.value;
53
+ }
54
+ if (operation === 'delete' && typeof response.value === 'boolean') return response.value;
55
+ if (operation === 'set' && response.value === true) return;
56
+ throw storeError(message);
57
+ }
58
+ return {
59
+ get: (service, name) => request('get', service, name),
60
+ set: (service, name, value) => request('set', service, name, value),
61
+ delete: (service, name) => request('delete', service, name),
62
+ };
63
+ }
@@ -0,0 +1,138 @@
1
+ $ErrorActionPreference = 'Stop'
2
+ $ProgressPreference = 'SilentlyContinue'
3
+ try {
4
+ if ($ExecutionContext.SessionState.LanguageMode -ne 'FullLanguage') {
5
+ throw 'Unavailable'
6
+ }
7
+ [Console]::InputEncoding = [System.Text.UTF8Encoding]::new($false, $true)
8
+ [Console]::OutputEncoding = [System.Text.UTF8Encoding]::new($false, $true)
9
+ Add-Type -TypeDefinition @'
10
+ using System;
11
+ using System.ComponentModel;
12
+ using System.Runtime.InteropServices;
13
+ using System.Text;
14
+
15
+ public static class SecretsCredentials {
16
+ [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)]
17
+ private struct Credential {
18
+ public uint Flags;
19
+ public uint Type;
20
+ public IntPtr TargetName;
21
+ public IntPtr Comment;
22
+ public System.Runtime.InteropServices.ComTypes.FILETIME LastWritten;
23
+ public uint CredentialBlobSize;
24
+ public IntPtr CredentialBlob;
25
+ public uint Persist;
26
+ public uint AttributeCount;
27
+ public IntPtr Attributes;
28
+ public IntPtr TargetAlias;
29
+ public IntPtr UserName;
30
+ }
31
+
32
+ [DllImport("advapi32.dll", EntryPoint = "CredWriteW", CharSet = CharSet.Unicode, ExactSpelling = true, SetLastError = true)]
33
+ [return: MarshalAs(UnmanagedType.Bool)]
34
+ private static extern bool CredWrite(ref Credential credential, uint flags);
35
+ [DllImport("advapi32.dll", EntryPoint = "CredReadW", CharSet = CharSet.Unicode, ExactSpelling = true, SetLastError = true)]
36
+ [return: MarshalAs(UnmanagedType.Bool)]
37
+ private static extern bool CredRead(string target, uint type, uint flags, out IntPtr credential);
38
+ [DllImport("advapi32.dll", EntryPoint = "CredDeleteW", CharSet = CharSet.Unicode, ExactSpelling = true, SetLastError = true)]
39
+ [return: MarshalAs(UnmanagedType.Bool)]
40
+ private static extern bool CredDelete(string target, uint type, uint flags);
41
+ [DllImport("advapi32.dll", ExactSpelling = true)]
42
+ private static extern void CredFree(IntPtr credential);
43
+
44
+ private static readonly Encoding Utf8 = new UTF8Encoding(false, true);
45
+ private static void Zero(IntPtr pointer, int length) {
46
+ if (pointer != IntPtr.Zero) for (int i = 0; i < length; i++) Marshal.WriteByte(pointer, i, 0);
47
+ }
48
+ public static string Target(string service, string name) {
49
+ // Length framing avoids both delimiter collisions and JSON serializer differences.
50
+ byte[] a = Utf8.GetBytes(service), b = Utf8.GetBytes(name);
51
+ if (a.Length > 256 || b.Length > 256) throw new ArgumentException();
52
+ byte[] framed = new byte[4 + a.Length + b.Length];
53
+ framed[0] = (byte)(a.Length >> 8); framed[1] = (byte)a.Length;
54
+ framed[2] = (byte)(b.Length >> 8); framed[3] = (byte)b.Length;
55
+ Buffer.BlockCopy(a, 0, framed, 4, a.Length);
56
+ Buffer.BlockCopy(b, 0, framed, 4 + a.Length, b.Length);
57
+ // Credential targets are case-insensitive; hex preserves identity under case folding.
58
+ string target = "secrets:" + BitConverter.ToString(framed).Replace("-", "").ToLowerInvariant();
59
+ if (target.Length > 32767) throw new ArgumentException();
60
+ return target;
61
+ }
62
+ public static void Write(string target, string value) {
63
+ byte[] bytes = Utf8.GetBytes(value);
64
+ IntPtr blob = IntPtr.Zero, targetPointer = IntPtr.Zero;
65
+ try {
66
+ if (bytes.Length > 2560) throw new ArgumentException();
67
+ targetPointer = Marshal.StringToCoTaskMemUni(target);
68
+ // Allocate one byte even for an empty secret; its native length remains zero.
69
+ blob = Marshal.AllocHGlobal(Math.Max(1, bytes.Length));
70
+ Marshal.WriteByte(blob, 0, 0);
71
+ if (bytes.Length != 0) Marshal.Copy(bytes, 0, blob, bytes.Length);
72
+ Credential credential = new Credential {
73
+ Type = 1, TargetName = targetPointer, CredentialBlob = blob,
74
+ CredentialBlobSize = (uint)bytes.Length, Persist = 3
75
+ };
76
+ if (!CredWrite(ref credential, 0)) throw new Win32Exception(Marshal.GetLastWin32Error());
77
+ } finally {
78
+ if (blob != IntPtr.Zero) { Zero(blob, Math.Max(1, bytes.Length)); Marshal.FreeHGlobal(blob); }
79
+ if (targetPointer != IntPtr.Zero) Marshal.ZeroFreeCoTaskMemUnicode(targetPointer);
80
+ Array.Clear(bytes, 0, bytes.Length);
81
+ }
82
+ }
83
+ public static string Read(string target) {
84
+ IntPtr pointer = IntPtr.Zero;
85
+ Credential credential = new Credential();
86
+ byte[] bytes = null;
87
+ try {
88
+ if (!CredRead(target, 1, 0, out pointer)) throw new Win32Exception(Marshal.GetLastWin32Error());
89
+ credential = (Credential)Marshal.PtrToStructure(pointer, typeof(Credential));
90
+ if (credential.CredentialBlobSize > 2560 || (credential.CredentialBlobSize != 0 && credential.CredentialBlob == IntPtr.Zero)) throw new ArgumentException();
91
+ bytes = new byte[(int)credential.CredentialBlobSize];
92
+ if (bytes.Length != 0) Marshal.Copy(credential.CredentialBlob, bytes, 0, bytes.Length);
93
+ return Utf8.GetString(bytes);
94
+ } finally {
95
+ if (bytes != null) Array.Clear(bytes, 0, bytes.Length);
96
+ if (pointer != IntPtr.Zero) {
97
+ try {
98
+ // Rejected native lengths must not turn cleanup into an unbounded write.
99
+ Zero(credential.CredentialBlob, (int)Math.Min(credential.CredentialBlobSize, 2560u));
100
+ } finally { CredFree(pointer); }
101
+ }
102
+ }
103
+ }
104
+ public static bool Delete(string target) {
105
+ if (!CredDelete(target, 1, 0)) throw new Win32Exception(Marshal.GetLastWin32Error());
106
+ return true;
107
+ }
108
+ }
109
+ '@
110
+ $request = [Console]::In.ReadToEnd() | ConvertFrom-Json
111
+ if ($request.service -isnot [string] -or $request.name -isnot [string]) { throw 'Invalid request' }
112
+ $target = [SecretsCredentials]::Target($request.service, $request.name)
113
+ switch -CaseSensitive ($request.operation) {
114
+ 'get' { $value = [SecretsCredentials]::Read($target) }
115
+ 'set' {
116
+ if ($request.value -isnot [string]) { throw 'Invalid request' }
117
+ [SecretsCredentials]::Write($target, $request.value)
118
+ $value = $true
119
+ }
120
+ 'delete' { $value = [SecretsCredentials]::Delete($target) }
121
+ default { throw 'Invalid request' }
122
+ }
123
+ [Console]::Out.WriteLine((ConvertTo-Json -Compress -InputObject @{ value = $value }))
124
+ exit 0
125
+ } catch {
126
+ # Never serialize exception messages: native and PowerShell errors can contain input.
127
+ $code = 'ERR_SECRETS_STORE'
128
+ if ($ExecutionContext.SessionState.LanguageMode -ne 'FullLanguage') {
129
+ # Full-language APIs may themselves be forbidden, so use only a fixed ASCII literal.
130
+ Write-Output '{"error":"ERR_SECRETS_UNAVAILABLE"}'
131
+ exit 1
132
+ }
133
+ $exception = $_.Exception
134
+ while ($null -ne $exception.InnerException) { $exception = $exception.InnerException }
135
+ if ($exception -is [System.ComponentModel.Win32Exception]) { $code = $exception.NativeErrorCode }
136
+ [Console]::Out.WriteLine((ConvertTo-Json -Compress -InputObject @{ error = $code }))
137
+ exit 1
138
+ }