@blamejs/core 0.8.43 → 0.8.50
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/CHANGELOG.md +93 -0
- package/README.md +10 -10
- package/index.js +52 -0
- package/lib/a2a.js +159 -34
- package/lib/acme.js +762 -0
- package/lib/ai-pref.js +166 -43
- package/lib/api-key.js +108 -47
- package/lib/api-snapshot.js +157 -40
- package/lib/app-shutdown.js +113 -77
- package/lib/archive.js +337 -40
- package/lib/arg-parser.js +697 -0
- package/lib/asyncapi.js +99 -55
- package/lib/atomic-file.js +465 -104
- package/lib/audit-chain.js +123 -34
- package/lib/audit-daily-review.js +389 -0
- package/lib/audit-sign.js +302 -56
- package/lib/audit-tools.js +412 -63
- package/lib/audit.js +656 -35
- package/lib/auth/jwt-external.js +17 -0
- package/lib/auth/oauth.js +7 -0
- package/lib/auth-bot-challenge.js +505 -0
- package/lib/auth-header.js +92 -25
- package/lib/backup/bundle.js +26 -0
- package/lib/backup/index.js +512 -89
- package/lib/backup/manifest.js +168 -7
- package/lib/break-glass.js +415 -39
- package/lib/budr.js +103 -30
- package/lib/bundler.js +86 -66
- package/lib/cache.js +192 -72
- package/lib/chain-writer.js +65 -40
- package/lib/circuit-breaker.js +56 -33
- package/lib/cli-helpers.js +106 -75
- package/lib/cli.js +6 -30
- package/lib/cloud-events.js +99 -32
- package/lib/cluster-storage.js +162 -37
- package/lib/cluster.js +340 -49
- package/lib/codepoint-class.js +66 -0
- package/lib/compliance.js +424 -24
- package/lib/config-drift.js +111 -46
- package/lib/config.js +94 -40
- package/lib/consent.js +165 -18
- package/lib/constants.js +1 -0
- package/lib/content-credentials.js +153 -48
- package/lib/cookies.js +154 -62
- package/lib/credential-hash.js +133 -61
- package/lib/crypto-field.js +702 -18
- package/lib/crypto-hpke.js +256 -0
- package/lib/crypto.js +744 -22
- package/lib/csv.js +178 -35
- package/lib/daemon.js +456 -0
- package/lib/dark-patterns.js +186 -55
- package/lib/db-query.js +79 -2
- package/lib/db.js +1431 -60
- package/lib/ddl-change-control.js +523 -0
- package/lib/deprecate.js +195 -40
- package/lib/dev.js +82 -39
- package/lib/dora.js +67 -48
- package/lib/dr-runbook.js +368 -0
- package/lib/dsr.js +142 -11
- package/lib/dual-control.js +91 -56
- package/lib/events.js +120 -41
- package/lib/external-db-migrate.js +192 -2
- package/lib/external-db.js +795 -50
- package/lib/fapi2.js +122 -1
- package/lib/fda-21cfr11.js +395 -0
- package/lib/fdx.js +132 -2
- package/lib/file-type.js +87 -0
- package/lib/file-upload.js +93 -0
- package/lib/flag.js +82 -20
- package/lib/forms.js +132 -29
- package/lib/framework-error.js +169 -0
- package/lib/framework-schema.js +163 -35
- package/lib/gate-contract.js +849 -175
- package/lib/graphql-federation.js +68 -7
- package/lib/guard-all.js +172 -55
- package/lib/guard-archive.js +286 -124
- package/lib/guard-auth.js +194 -21
- package/lib/guard-cidr.js +190 -28
- package/lib/guard-csv.js +397 -51
- package/lib/guard-domain.js +213 -91
- package/lib/guard-email.js +236 -29
- package/lib/guard-filename.js +307 -75
- package/lib/guard-graphql.js +263 -30
- package/lib/guard-html.js +310 -116
- package/lib/guard-image.js +243 -30
- package/lib/guard-json.js +260 -54
- package/lib/guard-jsonpath.js +235 -23
- package/lib/guard-jwt.js +284 -30
- package/lib/guard-markdown.js +204 -22
- package/lib/guard-mime.js +190 -26
- package/lib/guard-oauth.js +277 -28
- package/lib/guard-pdf.js +251 -27
- package/lib/guard-regex.js +226 -18
- package/lib/guard-shell.js +229 -26
- package/lib/guard-svg.js +177 -10
- package/lib/guard-template.js +232 -21
- package/lib/guard-time.js +195 -29
- package/lib/guard-uuid.js +189 -30
- package/lib/guard-xml.js +259 -36
- package/lib/guard-yaml.js +241 -44
- package/lib/honeytoken.js +63 -27
- package/lib/html-balance.js +83 -0
- package/lib/http-client.js +486 -59
- package/lib/http-message-signature.js +582 -0
- package/lib/i18n.js +102 -49
- package/lib/iab-mspa.js +112 -32
- package/lib/iab-tcf.js +107 -2
- package/lib/inbox.js +90 -52
- package/lib/keychain.js +865 -0
- package/lib/legal-hold.js +374 -0
- package/lib/local-db-thin.js +320 -0
- package/lib/log-stream.js +281 -51
- package/lib/log.js +184 -86
- package/lib/mail-bounce.js +107 -62
- package/lib/mail.js +295 -58
- package/lib/mcp.js +108 -27
- package/lib/metrics.js +98 -89
- package/lib/middleware/age-gate.js +36 -0
- package/lib/middleware/ai-act-disclosure.js +37 -0
- package/lib/middleware/api-encrypt.js +45 -0
- package/lib/middleware/assetlinks.js +40 -0
- package/lib/middleware/asyncapi-serve.js +35 -0
- package/lib/middleware/attach-user.js +40 -0
- package/lib/middleware/bearer-auth.js +40 -0
- package/lib/middleware/body-parser.js +230 -0
- package/lib/middleware/bot-disclose.js +34 -0
- package/lib/middleware/bot-guard.js +39 -0
- package/lib/middleware/compression.js +37 -0
- package/lib/middleware/cookies.js +32 -0
- package/lib/middleware/cors.js +40 -0
- package/lib/middleware/csp-nonce.js +40 -0
- package/lib/middleware/csp-report.js +34 -0
- package/lib/middleware/csrf-protect.js +43 -0
- package/lib/middleware/daily-byte-quota.js +53 -85
- package/lib/middleware/db-role-for.js +40 -0
- package/lib/middleware/dpop.js +40 -0
- package/lib/middleware/error-handler.js +37 -14
- package/lib/middleware/fetch-metadata.js +39 -0
- package/lib/middleware/flag-context.js +34 -0
- package/lib/middleware/gpc.js +33 -0
- package/lib/middleware/headers.js +35 -0
- package/lib/middleware/health.js +46 -0
- package/lib/middleware/host-allowlist.js +30 -0
- package/lib/middleware/network-allowlist.js +38 -0
- package/lib/middleware/openapi-serve.js +34 -0
- package/lib/middleware/rate-limit.js +160 -18
- package/lib/middleware/request-id.js +36 -18
- package/lib/middleware/request-log.js +37 -0
- package/lib/middleware/require-aal.js +29 -0
- package/lib/middleware/require-auth.js +32 -0
- package/lib/middleware/require-bound-key.js +41 -0
- package/lib/middleware/require-content-type.js +32 -0
- package/lib/middleware/require-methods.js +27 -0
- package/lib/middleware/require-mtls.js +33 -0
- package/lib/middleware/require-step-up.js +37 -0
- package/lib/middleware/security-headers.js +44 -0
- package/lib/middleware/security-txt.js +38 -0
- package/lib/middleware/span-http-server.js +37 -0
- package/lib/middleware/sse.js +36 -0
- package/lib/middleware/trace-log-correlation.js +33 -0
- package/lib/middleware/trace-propagate.js +32 -0
- package/lib/middleware/tus-upload.js +90 -0
- package/lib/middleware/web-app-manifest.js +53 -0
- package/lib/mtls-ca.js +100 -70
- package/lib/network-byte-quota.js +308 -0
- package/lib/network-heartbeat.js +135 -0
- package/lib/network-tls.js +534 -4
- package/lib/network.js +103 -0
- package/lib/notify.js +114 -43
- package/lib/ntp-check.js +192 -51
- package/lib/observability.js +145 -47
- package/lib/openapi.js +90 -44
- package/lib/outbox.js +99 -1
- package/lib/pagination.js +168 -86
- package/lib/parsers/index.js +16 -5
- package/lib/permissions.js +93 -40
- package/lib/pqc-agent.js +84 -8
- package/lib/pqc-software.js +94 -60
- package/lib/process-spawn.js +95 -21
- package/lib/pubsub.js +96 -66
- package/lib/queue.js +375 -54
- package/lib/redact.js +793 -21
- package/lib/render.js +139 -47
- package/lib/request-helpers.js +485 -121
- package/lib/restore-bundle.js +142 -39
- package/lib/restore-rollback.js +136 -45
- package/lib/retention.js +178 -50
- package/lib/retry.js +116 -33
- package/lib/router.js +475 -23
- package/lib/safe-async.js +543 -94
- package/lib/safe-buffer.js +337 -41
- package/lib/safe-json.js +467 -62
- package/lib/safe-jsonpath.js +285 -0
- package/lib/safe-schema.js +631 -87
- package/lib/safe-sql.js +221 -59
- package/lib/safe-url.js +278 -46
- package/lib/sandbox-worker.js +135 -0
- package/lib/sandbox.js +358 -0
- package/lib/scheduler.js +135 -70
- package/lib/self-update.js +647 -0
- package/lib/session-device-binding.js +431 -0
- package/lib/session.js +259 -49
- package/lib/slug.js +138 -26
- package/lib/ssrf-guard.js +316 -56
- package/lib/storage.js +433 -70
- package/lib/subject.js +405 -23
- package/lib/template.js +148 -8
- package/lib/tenant-quota.js +545 -0
- package/lib/testing.js +440 -53
- package/lib/time.js +291 -23
- package/lib/tls-exporter.js +239 -0
- package/lib/tracing.js +90 -74
- package/lib/uuid.js +97 -22
- package/lib/vault/index.js +284 -22
- package/lib/vault/seal-pem-file.js +66 -0
- package/lib/watcher.js +368 -0
- package/lib/webhook.js +196 -63
- package/lib/websocket.js +393 -68
- package/lib/wiki-concepts.js +338 -0
- package/lib/worker-pool.js +464 -0
- package/package.json +3 -3
- package/sbom.cyclonedx.json +7 -7
package/lib/keychain.js
ADDED
|
@@ -0,0 +1,865 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* @module b.keychain
|
|
4
|
+
* @nav Crypto
|
|
5
|
+
* @title Keychain
|
|
6
|
+
*
|
|
7
|
+
* @intro
|
|
8
|
+
* OS keychain abstraction with encrypted-file fallback — stores /
|
|
9
|
+
* retrieves / removes a `(service, account) -> password` binding via
|
|
10
|
+
* the host operating system's native credential store. Operators
|
|
11
|
+
* reach for this from CLI bootstraps that need to materialize a
|
|
12
|
+
* database password, outbound webhook secret, SMTP relay credential,
|
|
13
|
+
* etc., without baking the value into a config file or env var.
|
|
14
|
+
*
|
|
15
|
+
* Backend dispatch:
|
|
16
|
+
* - macOS -> `/usr/bin/security` (add-/find-/delete-generic-password)
|
|
17
|
+
* - Linux -> `secret-tool` from libsecret; password on stdin so
|
|
18
|
+
* it never reaches /proc/<pid>/cmdline
|
|
19
|
+
* - Windows -> PowerShell + the CredentialManager module; password
|
|
20
|
+
* on stdin via [Console]::In.ReadToEnd(). Falls through
|
|
21
|
+
* to the file backend when CredentialManager is absent
|
|
22
|
+
* - File -> XChaCha20-Poly1305-sealed JSON whose KEK is derived
|
|
23
|
+
* via Argon2id from `opts.passphrase`. Wrap format is
|
|
24
|
+
* shared with `b.vault.wrap` (magic 0xE2). File mode
|
|
25
|
+
* 0o600, atomic via `b.atomicFile.write`.
|
|
26
|
+
*
|
|
27
|
+
* Process-list-safety: every native-tool invocation passes the
|
|
28
|
+
* password on stdin. macOS `security` is invoked with `-w -` (the
|
|
29
|
+
* documented stdin sentinel); secret-tool always reads from stdin;
|
|
30
|
+
* PowerShell scripts use `[Console]::In.ReadToEnd()`. The plaintext
|
|
31
|
+
* never crosses argv on any backend.
|
|
32
|
+
*
|
|
33
|
+
* Validation tier: config-time / entry-point. Bad opts throw
|
|
34
|
+
* `KeychainError` synchronously; native-tool failures surface as
|
|
35
|
+
* `KeychainError` with the tool's stderr included.
|
|
36
|
+
*
|
|
37
|
+
* Audit: every call emits one of `keychain.stored` / `keychain.retrieved`
|
|
38
|
+
* / `keychain.removed` (or the `.failed` sibling). Audit metadata
|
|
39
|
+
* records service / account / backend / outcome. The password value
|
|
40
|
+
* is never audited.
|
|
41
|
+
*
|
|
42
|
+
* @card
|
|
43
|
+
* OS keychain abstraction with encrypted-file fallback — stores / retrieves / removes a `(service, account) -> password` binding via the host operating system's native credential store.
|
|
44
|
+
*/
|
|
45
|
+
|
|
46
|
+
var fs = require("fs");
|
|
47
|
+
var path = require("path");
|
|
48
|
+
|
|
49
|
+
var atomicFile = require("./atomic-file");
|
|
50
|
+
var C = require("./constants");
|
|
51
|
+
var lazyRequire = require("./lazy-require");
|
|
52
|
+
var processSpawn = require("./process-spawn");
|
|
53
|
+
var safeBuffer = require("./safe-buffer");
|
|
54
|
+
var safeEnv = require("./parsers/safe-env");
|
|
55
|
+
var safeJson = require("./safe-json");
|
|
56
|
+
var validateOpts = require("./validate-opts");
|
|
57
|
+
var vaultWrap = require("./vault/wrap");
|
|
58
|
+
var { FrameworkError, KeychainError } = require("./framework-error");
|
|
59
|
+
|
|
60
|
+
var audit = lazyRequire(function () { return require("./audit"); });
|
|
61
|
+
|
|
62
|
+
// ---- Backend detection -----------------------------------------------------
|
|
63
|
+
|
|
64
|
+
// Cached per-process so multiple calls don't re-stat /usr/bin/security
|
|
65
|
+
// on every invocation. Detection is best-effort — `null` means "fall
|
|
66
|
+
// through to the next backend / file fallback".
|
|
67
|
+
var _cachedBackend = null;
|
|
68
|
+
|
|
69
|
+
function _detectBackend() {
|
|
70
|
+
if (_cachedBackend !== null) return _cachedBackend;
|
|
71
|
+
var p = process.platform;
|
|
72
|
+
if (p === "darwin") {
|
|
73
|
+
if (_existsExecutable("/usr/bin/security")) {
|
|
74
|
+
_cachedBackend = "macos-security";
|
|
75
|
+
return _cachedBackend;
|
|
76
|
+
}
|
|
77
|
+
} else if (p === "linux") {
|
|
78
|
+
if (_resolveOnPath("secret-tool")) {
|
|
79
|
+
_cachedBackend = "linux-secret-tool";
|
|
80
|
+
return _cachedBackend;
|
|
81
|
+
}
|
|
82
|
+
} else if (p === "win32") {
|
|
83
|
+
if (_resolveOnPath("powershell.exe") || _resolveOnPath("pwsh.exe")) {
|
|
84
|
+
_cachedBackend = "windows-credential";
|
|
85
|
+
return _cachedBackend;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
_cachedBackend = null;
|
|
89
|
+
return null;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function _existsExecutable(filepath) {
|
|
93
|
+
try {
|
|
94
|
+
var st = fs.statSync(filepath);
|
|
95
|
+
return st.isFile();
|
|
96
|
+
} catch (_e) { return false; }
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// Allowlist of bin names this module is permitted to resolve on PATH.
|
|
100
|
+
// Frozen so a future caller can't smuggle an attacker-controlled name
|
|
101
|
+
// through _resolveOnPath — the lookup is gated by hardcoded membership
|
|
102
|
+
// in this set, not by any operator-supplied opts.
|
|
103
|
+
var _PATH_RESOLVE_ALLOWLIST = Object.freeze({
|
|
104
|
+
"secret-tool": true,
|
|
105
|
+
"powershell.exe": true,
|
|
106
|
+
"pwsh.exe": true,
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
function _resolveOnPath(binName) {
|
|
110
|
+
if (typeof binName !== "string" || _PATH_RESOLVE_ALLOWLIST[binName] !== true) {
|
|
111
|
+
return null;
|
|
112
|
+
}
|
|
113
|
+
// Reject any bin name with a path separator — defense in depth on top
|
|
114
|
+
// of the allowlist; a future contributor adding to the allowlist
|
|
115
|
+
// can't accidentally land a value with a "/" or "\" in it.
|
|
116
|
+
if (binName.indexOf("/") !== -1 || binName.indexOf("\\") !== -1) return null;
|
|
117
|
+
// Windows env vars are case-insensitive; Node populates both PATH and Path.
|
|
118
|
+
// safeEnv.readVar gates each by name with the standard size cap.
|
|
119
|
+
var pathEnv = safeEnv.readVar("PATH", { default: "" }) ||
|
|
120
|
+
safeEnv.readVar("Path", { default: "" }) ||
|
|
121
|
+
"";
|
|
122
|
+
var sep = process.platform === "win32" ? ";" : ":";
|
|
123
|
+
var parts = pathEnv.split(sep);
|
|
124
|
+
for (var i = 0; i < parts.length; i += 1) {
|
|
125
|
+
var dir = parts[i];
|
|
126
|
+
if (typeof dir !== "string" || dir.length === 0) continue;
|
|
127
|
+
var candidate = path.join(dir, binName);
|
|
128
|
+
if (_existsExecutable(candidate)) return candidate;
|
|
129
|
+
}
|
|
130
|
+
return null;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// ---- Opts validation -------------------------------------------------------
|
|
134
|
+
|
|
135
|
+
function _validateCommonOpts(opts, primitive) {
|
|
136
|
+
if (opts == null || typeof opts !== "object") {
|
|
137
|
+
throw new KeychainError("keychain/bad-opts",
|
|
138
|
+
primitive + ": opts must be an object");
|
|
139
|
+
}
|
|
140
|
+
validateOpts(opts, [
|
|
141
|
+
"service", "account", "password", "fallbackFile", "passphrase",
|
|
142
|
+
"preferFile", "audit",
|
|
143
|
+
], primitive);
|
|
144
|
+
validateOpts.requireNonEmptyString(opts.service, "service",
|
|
145
|
+
KeychainError, "keychain/bad-service");
|
|
146
|
+
validateOpts.requireNonEmptyString(opts.account, "account",
|
|
147
|
+
KeychainError, "keychain/bad-account");
|
|
148
|
+
// Service / account names cross argv on every native backend. Refuse
|
|
149
|
+
// newline / null bytes universally — they enable command injection on
|
|
150
|
+
// secret-tool's attribute-list parser and embed-an-extra-key on
|
|
151
|
+
// PowerShell's -Target string.
|
|
152
|
+
// eslint-disable-next-line no-control-regex
|
|
153
|
+
if (/[\u0000\r\n]/.test(opts.service) || /[\u0000\r\n]/.test(opts.account)) {
|
|
154
|
+
throw new KeychainError("keychain/bad-identifier",
|
|
155
|
+
primitive + ": service/account must not contain null or newline bytes");
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function _validateFallbackFile(filepath, primitive) {
|
|
160
|
+
validateOpts.requireNonEmptyString(filepath, "fallbackFile",
|
|
161
|
+
KeychainError, "keychain/bad-fallback-file");
|
|
162
|
+
if (!path.isAbsolute(filepath)) {
|
|
163
|
+
throw new KeychainError("keychain/relative-fallback-file",
|
|
164
|
+
primitive + ": fallbackFile must be an absolute path; got " + filepath);
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// ---- File-fallback I/O -----------------------------------------------------
|
|
169
|
+
//
|
|
170
|
+
// File format: vault.wrap-sealed buffer whose plaintext is a canonical
|
|
171
|
+
// JSON document of the shape
|
|
172
|
+
//
|
|
173
|
+
// { version: 1, entries: { "<service>\u0000<account>": "<password>" } }
|
|
174
|
+
//
|
|
175
|
+
// One file holds every binding for the operator's process. Atomic
|
|
176
|
+
// rename on every write (atomicFile.write) so a crash never leaves a
|
|
177
|
+
// half-written ciphertext at `fallbackFile`.
|
|
178
|
+
|
|
179
|
+
var FILE_FORMAT_VERSION = 1;
|
|
180
|
+
var FILE_KEY_SEPARATOR = "\u0000";
|
|
181
|
+
|
|
182
|
+
function _bindingKey(service, account) {
|
|
183
|
+
return service + FILE_KEY_SEPARATOR + account;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
async function _readFile(fallbackFile, passphrase) {
|
|
187
|
+
if (!atomicFile.exists(fallbackFile)) {
|
|
188
|
+
return { version: FILE_FORMAT_VERSION, entries: {} };
|
|
189
|
+
}
|
|
190
|
+
validateOpts.requireNonEmptyString(passphrase, "passphrase",
|
|
191
|
+
KeychainError, "keychain/file-passphrase-required");
|
|
192
|
+
var sealed = await atomicFile.read(fallbackFile, {
|
|
193
|
+
maxBytes: C.BYTES.mib(4),
|
|
194
|
+
});
|
|
195
|
+
if (!Buffer.isBuffer(sealed)) sealed = Buffer.from(sealed);
|
|
196
|
+
var pwBuf = Buffer.from(String(passphrase), "utf8");
|
|
197
|
+
var plaintext;
|
|
198
|
+
try {
|
|
199
|
+
plaintext = await vaultWrap.unwrap(sealed, pwBuf);
|
|
200
|
+
} catch (_e) {
|
|
201
|
+
throw new KeychainError("keychain/file-unseal-failed",
|
|
202
|
+
"fallback file passphrase rejected or file corrupted");
|
|
203
|
+
} finally {
|
|
204
|
+
safeBuffer.secureZero(pwBuf);
|
|
205
|
+
}
|
|
206
|
+
var doc;
|
|
207
|
+
try {
|
|
208
|
+
doc = safeJson.parse(plaintext.toString("utf8"));
|
|
209
|
+
} catch (_e) {
|
|
210
|
+
safeBuffer.secureZero(plaintext);
|
|
211
|
+
throw new KeychainError("keychain/file-bad-shape",
|
|
212
|
+
"fallback file payload is not valid JSON");
|
|
213
|
+
}
|
|
214
|
+
safeBuffer.secureZero(plaintext);
|
|
215
|
+
if (!doc || typeof doc !== "object" || doc.version !== FILE_FORMAT_VERSION ||
|
|
216
|
+
!doc.entries || typeof doc.entries !== "object") {
|
|
217
|
+
throw new KeychainError("keychain/file-bad-shape",
|
|
218
|
+
"fallback file is not a keychain document");
|
|
219
|
+
}
|
|
220
|
+
return doc;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
async function _writeFile(fallbackFile, doc, passphrase) {
|
|
224
|
+
validateOpts.requireNonEmptyString(passphrase, "passphrase",
|
|
225
|
+
KeychainError, "keychain/file-passphrase-required");
|
|
226
|
+
var serialized = Buffer.from(safeJson.canonical(doc), "utf8");
|
|
227
|
+
var pwBuf = Buffer.from(String(passphrase), "utf8");
|
|
228
|
+
var sealed;
|
|
229
|
+
try {
|
|
230
|
+
sealed = await vaultWrap.wrap(serialized, pwBuf);
|
|
231
|
+
} finally {
|
|
232
|
+
safeBuffer.secureZero(pwBuf);
|
|
233
|
+
safeBuffer.secureZero(serialized);
|
|
234
|
+
}
|
|
235
|
+
// atomicFile.write enforces 0o600 by default and writes via
|
|
236
|
+
// temp + fsync + rename so a crash never leaves a partial file.
|
|
237
|
+
await atomicFile.write(fallbackFile, sealed, { fileMode: 0o600 });
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// ---- Native-backend invocations -------------------------------------------
|
|
241
|
+
|
|
242
|
+
// Drain a stream into a Buffer, honoring an upper byte cap so a
|
|
243
|
+
// runaway tool can't OOM the framework.
|
|
244
|
+
function _drain(stream, capBytes) {
|
|
245
|
+
return new Promise(function (resolve, reject) {
|
|
246
|
+
if (!stream) { resolve(Buffer.alloc(0)); return; }
|
|
247
|
+
var collector = safeBuffer.boundedChunkCollector({
|
|
248
|
+
maxBytes: capBytes,
|
|
249
|
+
errorClass: KeychainError,
|
|
250
|
+
sizeCode: "keychain/native-output-too-large",
|
|
251
|
+
sizeMessage: "native tool output exceeded " + capBytes + " bytes",
|
|
252
|
+
});
|
|
253
|
+
stream.on("data", function (chunk) {
|
|
254
|
+
try {
|
|
255
|
+
collector.push(chunk);
|
|
256
|
+
} catch (e) {
|
|
257
|
+
try { stream.destroy(); } catch (_e) { /* destroy best-effort */ }
|
|
258
|
+
reject(e);
|
|
259
|
+
}
|
|
260
|
+
});
|
|
261
|
+
stream.on("end", function () { resolve(collector.result()); });
|
|
262
|
+
stream.on("error", function (e) { reject(e); });
|
|
263
|
+
});
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
function _runNative(command, args, opts) {
|
|
267
|
+
opts = opts || {};
|
|
268
|
+
var stdinBuf = opts.stdin == null ? null
|
|
269
|
+
: (Buffer.isBuffer(opts.stdin) ? opts.stdin : Buffer.from(String(opts.stdin), "utf8"));
|
|
270
|
+
return new Promise(function (resolve, reject) {
|
|
271
|
+
var child;
|
|
272
|
+
try {
|
|
273
|
+
child = processSpawn.spawn(command, args || [], {
|
|
274
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
275
|
+
});
|
|
276
|
+
} catch (e) { reject(e); return; }
|
|
277
|
+
|
|
278
|
+
var stdoutCap = opts.maxStdoutBytes || C.BYTES.mib(1);
|
|
279
|
+
var stderrCap = opts.maxStderrBytes || C.BYTES.kib(64);
|
|
280
|
+
var settled = false;
|
|
281
|
+
var outP = _drain(child.stdout, stdoutCap);
|
|
282
|
+
var errP = _drain(child.stderr, stderrCap);
|
|
283
|
+
|
|
284
|
+
child.on("error", function (e) {
|
|
285
|
+
if (settled) return;
|
|
286
|
+
settled = true;
|
|
287
|
+
if (stdinBuf) safeBuffer.secureZero(stdinBuf);
|
|
288
|
+
reject(e);
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
child.on("close", function (code, signal) {
|
|
292
|
+
Promise.all([outP, errP]).then(function (bufs) {
|
|
293
|
+
if (settled) return;
|
|
294
|
+
settled = true;
|
|
295
|
+
if (stdinBuf) safeBuffer.secureZero(stdinBuf);
|
|
296
|
+
resolve({
|
|
297
|
+
code: typeof code === "number" ? code : -1,
|
|
298
|
+
signal: signal || null,
|
|
299
|
+
stdout: bufs[0],
|
|
300
|
+
stderr: bufs[1],
|
|
301
|
+
});
|
|
302
|
+
}, function (e) {
|
|
303
|
+
if (settled) return;
|
|
304
|
+
settled = true;
|
|
305
|
+
if (stdinBuf) safeBuffer.secureZero(stdinBuf);
|
|
306
|
+
reject(e);
|
|
307
|
+
});
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
if (stdinBuf && child.stdin) {
|
|
311
|
+
try {
|
|
312
|
+
child.stdin.on("error", function (_e) { /* broken pipe is fine */ });
|
|
313
|
+
child.stdin.end(stdinBuf);
|
|
314
|
+
} catch (_e) { /* tool may have closed stdin already */ }
|
|
315
|
+
} else if (child.stdin) {
|
|
316
|
+
try { child.stdin.end(); } catch (_e) { /* close best-effort */ }
|
|
317
|
+
}
|
|
318
|
+
});
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
// ---- macOS: /usr/bin/security ---------------------------------------------
|
|
322
|
+
//
|
|
323
|
+
// add-generic-password supports `-w -` to read the password from stdin
|
|
324
|
+
// (man security(1) — "If pre-existing or read with -w -, the password
|
|
325
|
+
// is read from stdin"). The single dash is the documented sentinel.
|
|
326
|
+
|
|
327
|
+
async function _macStore(service, account, password) {
|
|
328
|
+
var r = await _runNative("/usr/bin/security", [
|
|
329
|
+
"add-generic-password",
|
|
330
|
+
"-s", service,
|
|
331
|
+
"-a", account,
|
|
332
|
+
"-w", "-", // read password from stdin
|
|
333
|
+
"-U", // update if exists
|
|
334
|
+
], { stdin: password });
|
|
335
|
+
if (r.code !== 0) {
|
|
336
|
+
throw new KeychainError("keychain/macos-store-failed",
|
|
337
|
+
"security add-generic-password exited " + r.code + ": " +
|
|
338
|
+
r.stderr.toString("utf8").trim());
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
async function _macRetrieve(service, account) {
|
|
343
|
+
var r = await _runNative("/usr/bin/security", [
|
|
344
|
+
"find-generic-password",
|
|
345
|
+
"-s", service,
|
|
346
|
+
"-a", account,
|
|
347
|
+
"-w", // print password on stdout
|
|
348
|
+
]);
|
|
349
|
+
if (r.code === 44) { // SecKeychainSearchCopyNext: not found
|
|
350
|
+
return null;
|
|
351
|
+
}
|
|
352
|
+
if (r.code !== 0) {
|
|
353
|
+
throw new KeychainError("keychain/macos-retrieve-failed",
|
|
354
|
+
"security find-generic-password exited " + r.code + ": " +
|
|
355
|
+
r.stderr.toString("utf8").trim());
|
|
356
|
+
}
|
|
357
|
+
// `security -w` prints the password followed by a newline.
|
|
358
|
+
var raw = r.stdout.toString("utf8");
|
|
359
|
+
if (raw.length > 0 && raw[raw.length - 1] === "\n") raw = raw.slice(0, -1);
|
|
360
|
+
return raw;
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
async function _macRemove(service, account) {
|
|
364
|
+
var r = await _runNative("/usr/bin/security", [
|
|
365
|
+
"delete-generic-password",
|
|
366
|
+
"-s", service,
|
|
367
|
+
"-a", account,
|
|
368
|
+
]);
|
|
369
|
+
if (r.code === 44) return false;
|
|
370
|
+
if (r.code !== 0) {
|
|
371
|
+
throw new KeychainError("keychain/macos-remove-failed",
|
|
372
|
+
"security delete-generic-password exited " + r.code + ": " +
|
|
373
|
+
r.stderr.toString("utf8").trim());
|
|
374
|
+
}
|
|
375
|
+
return true;
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
// ---- Linux: secret-tool ----------------------------------------------------
|
|
379
|
+
//
|
|
380
|
+
// `secret-tool store` reads the password from stdin only — there is no
|
|
381
|
+
// CLI flag for the value (man secret-tool — "Will prompt for the secret
|
|
382
|
+
// or read it from standard input if it isn't a TTY"). Stdin path is
|
|
383
|
+
// process-list-safe by construction.
|
|
384
|
+
|
|
385
|
+
async function _linuxStore(service, account, password) {
|
|
386
|
+
var r = await _runNative("secret-tool", [
|
|
387
|
+
"store",
|
|
388
|
+
"--label", service,
|
|
389
|
+
"service", service,
|
|
390
|
+
"account", account,
|
|
391
|
+
], { stdin: password });
|
|
392
|
+
if (r.code !== 0) {
|
|
393
|
+
throw new KeychainError("keychain/linux-store-failed",
|
|
394
|
+
"secret-tool store exited " + r.code + ": " +
|
|
395
|
+
r.stderr.toString("utf8").trim());
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
async function _linuxRetrieve(service, account) {
|
|
400
|
+
var r = await _runNative("secret-tool", [
|
|
401
|
+
"lookup",
|
|
402
|
+
"service", service,
|
|
403
|
+
"account", account,
|
|
404
|
+
]);
|
|
405
|
+
if (r.code !== 0) {
|
|
406
|
+
// secret-tool exits 1 when the attribute set has no match. Surface
|
|
407
|
+
// null instead of throwing — operators expect "not found" to return
|
|
408
|
+
// null rather than an error.
|
|
409
|
+
if (r.stderr.toString("utf8").indexOf("No matching") !== -1 || r.stdout.length === 0) {
|
|
410
|
+
return null;
|
|
411
|
+
}
|
|
412
|
+
throw new KeychainError("keychain/linux-retrieve-failed",
|
|
413
|
+
"secret-tool lookup exited " + r.code + ": " +
|
|
414
|
+
r.stderr.toString("utf8").trim());
|
|
415
|
+
}
|
|
416
|
+
var raw = r.stdout.toString("utf8");
|
|
417
|
+
// secret-tool does NOT append a newline (man secret-tool); guard
|
|
418
|
+
// anyway in case a future libsecret release changes that.
|
|
419
|
+
if (raw.length > 0 && raw[raw.length - 1] === "\n") raw = raw.slice(0, -1);
|
|
420
|
+
if (raw.length === 0) return null;
|
|
421
|
+
return raw;
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
async function _linuxRemove(service, account) {
|
|
425
|
+
var r = await _runNative("secret-tool", [
|
|
426
|
+
"clear",
|
|
427
|
+
"service", service,
|
|
428
|
+
"account", account,
|
|
429
|
+
]);
|
|
430
|
+
if (r.code !== 0) {
|
|
431
|
+
throw new KeychainError("keychain/linux-remove-failed",
|
|
432
|
+
"secret-tool clear exited " + r.code + ": " +
|
|
433
|
+
r.stderr.toString("utf8").trim());
|
|
434
|
+
}
|
|
435
|
+
return true;
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
// ---- Windows: PowerShell + CredentialManager module -----------------------
|
|
439
|
+
//
|
|
440
|
+
// CredentialManager (PSGallery) exposes Get-/New-/Remove-StoredCredential.
|
|
441
|
+
// We pipe the password to PowerShell on stdin via $cred = [Console]::In.
|
|
442
|
+
// ReadLine() so the plaintext never hits argv. When CredentialManager is
|
|
443
|
+
// not installed (`Get-Module -ListAvailable CredentialManager` empty),
|
|
444
|
+
// the calling host gets a not-supported error and the keychain falls
|
|
445
|
+
// through to the file fallback.
|
|
446
|
+
//
|
|
447
|
+
// The script reads: command (one of "store" / "retrieve" / "remove"),
|
|
448
|
+
// service, account from argv; password (store only) from stdin.
|
|
449
|
+
|
|
450
|
+
var _PS_SCRIPT_HEAD = "" +
|
|
451
|
+
"$ErrorActionPreference = 'Stop';" +
|
|
452
|
+
"if (-not (Get-Module -ListAvailable -Name CredentialManager)) {" +
|
|
453
|
+
"Write-Error 'CredentialManager module not installed';" +
|
|
454
|
+
"exit 2;" +
|
|
455
|
+
"}" +
|
|
456
|
+
"Import-Module CredentialManager;";
|
|
457
|
+
|
|
458
|
+
function _psQuote(value) {
|
|
459
|
+
// PowerShell single-quoted strings escape ' as ''.
|
|
460
|
+
return "'" + String(value).replace(/'/g, "''") + "'";
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
function _psStoreScript(service, account) {
|
|
464
|
+
var target = service + ":" + account;
|
|
465
|
+
return _PS_SCRIPT_HEAD +
|
|
466
|
+
"$pw = [Console]::In.ReadToEnd();" +
|
|
467
|
+
"if ($pw.EndsWith([char]10)) { $pw = $pw.Substring(0, $pw.Length - 1); }" +
|
|
468
|
+
"if ($pw.EndsWith([char]13)) { $pw = $pw.Substring(0, $pw.Length - 1); }" +
|
|
469
|
+
"$secure = ConvertTo-SecureString -String $pw -AsPlainText -Force;" +
|
|
470
|
+
"New-StoredCredential -Target " + _psQuote(target) +
|
|
471
|
+
" -UserName " + _psQuote(account) +
|
|
472
|
+
" -SecurePassword $secure -Persist LocalMachine | Out-Null;" +
|
|
473
|
+
"Write-Output 'OK';";
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
function _psRetrieveScript(service, account) {
|
|
477
|
+
var target = service + ":" + account;
|
|
478
|
+
return _PS_SCRIPT_HEAD +
|
|
479
|
+
"$cred = Get-StoredCredential -Target " + _psQuote(target) + ";" +
|
|
480
|
+
"if ($null -eq $cred) { exit 44; }" +
|
|
481
|
+
"$ptr = [Runtime.InteropServices.Marshal]::SecureStringToBSTR($cred.Password);" +
|
|
482
|
+
"try {" +
|
|
483
|
+
"$plain = [Runtime.InteropServices.Marshal]::PtrToStringAuto($ptr);" +
|
|
484
|
+
"[Console]::Out.Write($plain);" +
|
|
485
|
+
"} finally {" +
|
|
486
|
+
"[Runtime.InteropServices.Marshal]::ZeroFreeBSTR($ptr);" +
|
|
487
|
+
"}";
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
function _psRemoveScript(service, account) {
|
|
491
|
+
var target = service + ":" + account;
|
|
492
|
+
return _PS_SCRIPT_HEAD +
|
|
493
|
+
"try {" +
|
|
494
|
+
"Remove-StoredCredential -Target " + _psQuote(target) + ";" +
|
|
495
|
+
"Write-Output 'OK';" +
|
|
496
|
+
"} catch {" +
|
|
497
|
+
"if ($_.Exception.Message -match 'not be found') { exit 44; } else { throw; }" +
|
|
498
|
+
"}";
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
function _psResolve() {
|
|
502
|
+
var p = _resolveOnPath("pwsh.exe") || _resolveOnPath("powershell.exe");
|
|
503
|
+
if (!p) {
|
|
504
|
+
throw new KeychainError("keychain/windows-no-powershell",
|
|
505
|
+
"PowerShell executable not found on PATH");
|
|
506
|
+
}
|
|
507
|
+
return p;
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
async function _windowsStore(service, account, password) {
|
|
511
|
+
var r = await _runNative(_psResolve(), [
|
|
512
|
+
"-NoProfile", "-NonInteractive", "-Command", _psStoreScript(service, account),
|
|
513
|
+
], { stdin: password });
|
|
514
|
+
if (r.code === 2) {
|
|
515
|
+
var e = new KeychainError("keychain/windows-not-supported",
|
|
516
|
+
"CredentialManager PowerShell module not installed");
|
|
517
|
+
e.fallback = true;
|
|
518
|
+
throw e;
|
|
519
|
+
}
|
|
520
|
+
if (r.code !== 0) {
|
|
521
|
+
throw new KeychainError("keychain/windows-store-failed",
|
|
522
|
+
"PowerShell New-StoredCredential exited " + r.code + ": " +
|
|
523
|
+
r.stderr.toString("utf8").trim());
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
async function _windowsRetrieve(service, account) {
|
|
528
|
+
var r = await _runNative(_psResolve(), [
|
|
529
|
+
"-NoProfile", "-NonInteractive", "-Command", _psRetrieveScript(service, account),
|
|
530
|
+
]);
|
|
531
|
+
if (r.code === 2) {
|
|
532
|
+
var e = new KeychainError("keychain/windows-not-supported",
|
|
533
|
+
"CredentialManager PowerShell module not installed");
|
|
534
|
+
e.fallback = true;
|
|
535
|
+
throw e;
|
|
536
|
+
}
|
|
537
|
+
if (r.code === 44) return null;
|
|
538
|
+
if (r.code !== 0) {
|
|
539
|
+
throw new KeychainError("keychain/windows-retrieve-failed",
|
|
540
|
+
"PowerShell Get-StoredCredential exited " + r.code + ": " +
|
|
541
|
+
r.stderr.toString("utf8").trim());
|
|
542
|
+
}
|
|
543
|
+
return r.stdout.toString("utf8");
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
async function _windowsRemove(service, account) {
|
|
547
|
+
var r = await _runNative(_psResolve(), [
|
|
548
|
+
"-NoProfile", "-NonInteractive", "-Command", _psRemoveScript(service, account),
|
|
549
|
+
]);
|
|
550
|
+
if (r.code === 2) {
|
|
551
|
+
var e = new KeychainError("keychain/windows-not-supported",
|
|
552
|
+
"CredentialManager PowerShell module not installed");
|
|
553
|
+
e.fallback = true;
|
|
554
|
+
throw e;
|
|
555
|
+
}
|
|
556
|
+
if (r.code === 44) return false;
|
|
557
|
+
if (r.code !== 0) {
|
|
558
|
+
throw new KeychainError("keychain/windows-remove-failed",
|
|
559
|
+
"PowerShell Remove-StoredCredential exited " + r.code + ": " +
|
|
560
|
+
r.stderr.toString("utf8").trim());
|
|
561
|
+
}
|
|
562
|
+
return true;
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
// ---- Audit emit ------------------------------------------------------------
|
|
566
|
+
// drop-silent — by design (audit is best-effort observability).
|
|
567
|
+
|
|
568
|
+
function _emit(action, outcome, metadata, auditOn) {
|
|
569
|
+
if (auditOn === false) return;
|
|
570
|
+
try {
|
|
571
|
+
audit().safeEmit({
|
|
572
|
+
action: action,
|
|
573
|
+
outcome: outcome,
|
|
574
|
+
metadata: metadata || {},
|
|
575
|
+
});
|
|
576
|
+
} catch (_e) { /* audit best-effort */ }
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
// ---- Backend selection -----------------------------------------------------
|
|
580
|
+
|
|
581
|
+
function _selectBackend(opts) {
|
|
582
|
+
if (opts && opts.preferFile === true) return "file";
|
|
583
|
+
return _detectBackend() || "file";
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
function _isFallbackError(e) {
|
|
587
|
+
// A KeychainError flagged with .fallback === true means the native
|
|
588
|
+
// tool reported "not installed / unavailable" rather than an
|
|
589
|
+
// operational failure. Promote to file fallback transparently.
|
|
590
|
+
return e instanceof FrameworkError && e.fallback === true;
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
// ---- Public surface --------------------------------------------------------
|
|
594
|
+
|
|
595
|
+
/**
|
|
596
|
+
* @primitive b.keychain.store
|
|
597
|
+
* @signature b.keychain.store(opts)
|
|
598
|
+
* @since 0.7.0
|
|
599
|
+
* @related b.keychain.retrieve, b.keychain.remove
|
|
600
|
+
*
|
|
601
|
+
* Persist a `(service, account) -> password` binding to the
|
|
602
|
+
* platform's native credential store, falling back to an encrypted
|
|
603
|
+
* file when no native backend is reachable. The password crosses to
|
|
604
|
+
* the native tool on stdin so it never appears in `/proc/<pid>/cmdline`
|
|
605
|
+
* or `ps`. Resolves to `{ stored: true, backend }` on success.
|
|
606
|
+
* Bad opts throw `KeychainError` synchronously.
|
|
607
|
+
*
|
|
608
|
+
* Set `preferFile: true` to skip native backend probing entirely (for
|
|
609
|
+
* deterministic CI / disposable container deployments).
|
|
610
|
+
*
|
|
611
|
+
* @opts
|
|
612
|
+
* {
|
|
613
|
+
* service: string, // required, no NUL/CR/LF bytes
|
|
614
|
+
* account: string, // required, no NUL/CR/LF bytes
|
|
615
|
+
* password: string, // required, non-empty
|
|
616
|
+
* fallbackFile?: string, // absolute path; required if file fallback may engage
|
|
617
|
+
* passphrase?: string, // required when fallbackFile engages (Argon2id-derived KEK)
|
|
618
|
+
* preferFile?: boolean, // default: false
|
|
619
|
+
* audit?: boolean, // default: true (emits keychain.stored)
|
|
620
|
+
* }
|
|
621
|
+
*
|
|
622
|
+
* @example
|
|
623
|
+
* await b.keychain.store({
|
|
624
|
+
* service: "blamejs/db",
|
|
625
|
+
* account: "primary",
|
|
626
|
+
* password: "s3cr3t",
|
|
627
|
+
* fallbackFile: "/var/lib/blamejs/keychain.enc",
|
|
628
|
+
* passphrase: process.env.BLAMEJS_KEYCHAIN_PASSPHRASE,
|
|
629
|
+
* });
|
|
630
|
+
* // → { stored: true, backend: "macos-security" }
|
|
631
|
+
*/
|
|
632
|
+
async function store(opts) {
|
|
633
|
+
_validateCommonOpts(opts, "keychain.store");
|
|
634
|
+
validateOpts.requireNonEmptyString(opts.password, "password",
|
|
635
|
+
KeychainError, "keychain/bad-password");
|
|
636
|
+
|
|
637
|
+
var backend = _selectBackend(opts);
|
|
638
|
+
var auditOn = opts.audit !== false;
|
|
639
|
+
|
|
640
|
+
if (backend !== "file") {
|
|
641
|
+
try {
|
|
642
|
+
if (backend === "macos-security") await _macStore(opts.service, opts.account, opts.password);
|
|
643
|
+
else if (backend === "linux-secret-tool") await _linuxStore(opts.service, opts.account, opts.password);
|
|
644
|
+
else if (backend === "windows-credential") await _windowsStore(opts.service, opts.account, opts.password);
|
|
645
|
+
_emit("keychain.stored", "success", {
|
|
646
|
+
service: opts.service, account: opts.account, backend: backend,
|
|
647
|
+
}, auditOn);
|
|
648
|
+
return { stored: true, backend: backend };
|
|
649
|
+
} catch (e) {
|
|
650
|
+
if (!_isFallbackError(e)) {
|
|
651
|
+
_emit("keychain.stored", "failure", {
|
|
652
|
+
service: opts.service, account: opts.account, backend: backend,
|
|
653
|
+
code: e && e.code, message: e && e.message,
|
|
654
|
+
}, auditOn);
|
|
655
|
+
throw e;
|
|
656
|
+
}
|
|
657
|
+
// fallthrough to file fallback
|
|
658
|
+
}
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
_validateFallbackFile(opts.fallbackFile, "keychain.store");
|
|
662
|
+
var doc = await _readFile(opts.fallbackFile, opts.passphrase);
|
|
663
|
+
doc.entries[_bindingKey(opts.service, opts.account)] = String(opts.password);
|
|
664
|
+
await _writeFile(opts.fallbackFile, doc, opts.passphrase);
|
|
665
|
+
_emit("keychain.stored", "success", {
|
|
666
|
+
service: opts.service, account: opts.account, backend: "file",
|
|
667
|
+
}, auditOn);
|
|
668
|
+
return { stored: true, backend: "file" };
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
/**
|
|
672
|
+
* @primitive b.keychain.retrieve
|
|
673
|
+
* @signature b.keychain.retrieve(opts)
|
|
674
|
+
* @since 0.7.0
|
|
675
|
+
* @related b.keychain.store, b.keychain.remove
|
|
676
|
+
*
|
|
677
|
+
* Look up the password for `(service, account)` from the native
|
|
678
|
+
* credential store, falling back to the encrypted file when the
|
|
679
|
+
* native store has no entry or no native backend is reachable.
|
|
680
|
+
* Resolves to `{ password, backend }` on a hit, `null` on a clean
|
|
681
|
+
* miss. Native-tool failures surface as `KeychainError` with the
|
|
682
|
+
* tool's stderr included.
|
|
683
|
+
*
|
|
684
|
+
* @opts
|
|
685
|
+
* {
|
|
686
|
+
* service: string, // required
|
|
687
|
+
* account: string, // required
|
|
688
|
+
* fallbackFile?: string, // absolute path; required for file-backend lookup
|
|
689
|
+
* passphrase?: string, // required when fallbackFile engages
|
|
690
|
+
* preferFile?: boolean, // default: false
|
|
691
|
+
* audit?: boolean, // default: true (emits keychain.retrieved)
|
|
692
|
+
* }
|
|
693
|
+
*
|
|
694
|
+
* @example
|
|
695
|
+
* var got = await b.keychain.retrieve({
|
|
696
|
+
* service: "blamejs/db",
|
|
697
|
+
* account: "primary",
|
|
698
|
+
* fallbackFile: "/var/lib/blamejs/keychain.enc",
|
|
699
|
+
* passphrase: process.env.BLAMEJS_KEYCHAIN_PASSPHRASE,
|
|
700
|
+
* });
|
|
701
|
+
* // → { password: "s3cr3t", backend: "macos-security" } // or null on miss
|
|
702
|
+
*/
|
|
703
|
+
async function retrieve(opts) {
|
|
704
|
+
_validateCommonOpts(opts, "keychain.retrieve");
|
|
705
|
+
|
|
706
|
+
var backend = _selectBackend(opts);
|
|
707
|
+
var auditOn = opts.audit !== false;
|
|
708
|
+
|
|
709
|
+
if (backend !== "file") {
|
|
710
|
+
try {
|
|
711
|
+
var pw = null;
|
|
712
|
+
if (backend === "macos-security") pw = await _macRetrieve(opts.service, opts.account);
|
|
713
|
+
else if (backend === "linux-secret-tool") pw = await _linuxRetrieve(opts.service, opts.account);
|
|
714
|
+
else if (backend === "windows-credential") pw = await _windowsRetrieve(opts.service, opts.account);
|
|
715
|
+
if (pw === null || pw === undefined) {
|
|
716
|
+
// Fall through to file fallback when the OS keychain has no
|
|
717
|
+
// entry — operators may have stored under file mode and later
|
|
718
|
+
// booted on a host with a native keychain.
|
|
719
|
+
} else {
|
|
720
|
+
_emit("keychain.retrieved", "success", {
|
|
721
|
+
service: opts.service, account: opts.account, backend: backend,
|
|
722
|
+
}, auditOn);
|
|
723
|
+
return { password: pw, backend: backend };
|
|
724
|
+
}
|
|
725
|
+
} catch (e) {
|
|
726
|
+
if (!_isFallbackError(e)) {
|
|
727
|
+
_emit("keychain.retrieved", "failure", {
|
|
728
|
+
service: opts.service, account: opts.account, backend: backend,
|
|
729
|
+
code: e && e.code, message: e && e.message,
|
|
730
|
+
}, auditOn);
|
|
731
|
+
throw e;
|
|
732
|
+
}
|
|
733
|
+
}
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
if (!opts.fallbackFile) {
|
|
737
|
+
_emit("keychain.retrieved", "success", {
|
|
738
|
+
service: opts.service, account: opts.account, backend: "none",
|
|
739
|
+
found: false,
|
|
740
|
+
}, auditOn);
|
|
741
|
+
return null;
|
|
742
|
+
}
|
|
743
|
+
_validateFallbackFile(opts.fallbackFile, "keychain.retrieve");
|
|
744
|
+
if (!atomicFile.exists(opts.fallbackFile)) {
|
|
745
|
+
_emit("keychain.retrieved", "success", {
|
|
746
|
+
service: opts.service, account: opts.account, backend: "file",
|
|
747
|
+
found: false,
|
|
748
|
+
}, auditOn);
|
|
749
|
+
return null;
|
|
750
|
+
}
|
|
751
|
+
var doc = await _readFile(opts.fallbackFile, opts.passphrase);
|
|
752
|
+
var bindingKey = _bindingKey(opts.service, opts.account);
|
|
753
|
+
var found = Object.prototype.hasOwnProperty.call(doc.entries, bindingKey);
|
|
754
|
+
if (!found) {
|
|
755
|
+
_emit("keychain.retrieved", "success", {
|
|
756
|
+
service: opts.service, account: opts.account, backend: "file",
|
|
757
|
+
found: false,
|
|
758
|
+
}, auditOn);
|
|
759
|
+
return null;
|
|
760
|
+
}
|
|
761
|
+
_emit("keychain.retrieved", "success", {
|
|
762
|
+
service: opts.service, account: opts.account, backend: "file",
|
|
763
|
+
}, auditOn);
|
|
764
|
+
return { password: doc.entries[bindingKey], backend: "file" };
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
/**
|
|
768
|
+
* @primitive b.keychain.remove
|
|
769
|
+
* @signature b.keychain.remove(opts)
|
|
770
|
+
* @since 0.7.0
|
|
771
|
+
* @related b.keychain.store, b.keychain.retrieve
|
|
772
|
+
*
|
|
773
|
+
* Delete the `(service, account)` binding from both the native
|
|
774
|
+
* credential store (when reachable) and the encrypted file fallback
|
|
775
|
+
* (when `fallbackFile` is supplied). Resolves to `true` when at least
|
|
776
|
+
* one backend held the binding, `false` on a no-op. The double-sweep
|
|
777
|
+
* matters because a binding may have been stored on a prior boot
|
|
778
|
+
* under a different backend than the current host advertises.
|
|
779
|
+
*
|
|
780
|
+
* @opts
|
|
781
|
+
* {
|
|
782
|
+
* service: string, // required
|
|
783
|
+
* account: string, // required
|
|
784
|
+
* fallbackFile?: string, // absolute path; required for file-backend cleanup
|
|
785
|
+
* passphrase?: string, // required when fallbackFile engages
|
|
786
|
+
* preferFile?: boolean, // default: false
|
|
787
|
+
* audit?: boolean, // default: true (emits keychain.removed)
|
|
788
|
+
* }
|
|
789
|
+
*
|
|
790
|
+
* @example
|
|
791
|
+
* var existed = await b.keychain.remove({
|
|
792
|
+
* service: "blamejs/db",
|
|
793
|
+
* account: "primary",
|
|
794
|
+
* fallbackFile: "/var/lib/blamejs/keychain.enc",
|
|
795
|
+
* passphrase: process.env.BLAMEJS_KEYCHAIN_PASSPHRASE,
|
|
796
|
+
* });
|
|
797
|
+
* // → true
|
|
798
|
+
*/
|
|
799
|
+
async function remove(opts) {
|
|
800
|
+
_validateCommonOpts(opts, "keychain.remove");
|
|
801
|
+
|
|
802
|
+
var backend = _selectBackend(opts);
|
|
803
|
+
var auditOn = opts.audit !== false;
|
|
804
|
+
|
|
805
|
+
if (backend !== "file") {
|
|
806
|
+
try {
|
|
807
|
+
var ok = false;
|
|
808
|
+
if (backend === "macos-security") ok = await _macRemove(opts.service, opts.account);
|
|
809
|
+
else if (backend === "linux-secret-tool") ok = await _linuxRemove(opts.service, opts.account);
|
|
810
|
+
else if (backend === "windows-credential") ok = await _windowsRemove(opts.service, opts.account);
|
|
811
|
+
_emit("keychain.removed", ok ? "success" : "no-op", {
|
|
812
|
+
service: opts.service, account: opts.account, backend: backend,
|
|
813
|
+
}, auditOn);
|
|
814
|
+
// Also sweep file fallback if both could carry the binding.
|
|
815
|
+
if (opts.fallbackFile && atomicFile.exists(opts.fallbackFile)) {
|
|
816
|
+
try { await _removeFromFile(opts.fallbackFile, opts.service, opts.account, opts.passphrase); }
|
|
817
|
+
catch (_e) { /* file remove best-effort when native succeeded */ }
|
|
818
|
+
}
|
|
819
|
+
return ok;
|
|
820
|
+
} catch (e) {
|
|
821
|
+
if (!_isFallbackError(e)) {
|
|
822
|
+
_emit("keychain.removed", "failure", {
|
|
823
|
+
service: opts.service, account: opts.account, backend: backend,
|
|
824
|
+
code: e && e.code, message: e && e.message,
|
|
825
|
+
}, auditOn);
|
|
826
|
+
throw e;
|
|
827
|
+
}
|
|
828
|
+
}
|
|
829
|
+
}
|
|
830
|
+
|
|
831
|
+
if (!opts.fallbackFile || !atomicFile.exists(opts.fallbackFile)) {
|
|
832
|
+
_emit("keychain.removed", "no-op", {
|
|
833
|
+
service: opts.service, account: opts.account, backend: "file",
|
|
834
|
+
}, auditOn);
|
|
835
|
+
return false;
|
|
836
|
+
}
|
|
837
|
+
_validateFallbackFile(opts.fallbackFile, "keychain.remove");
|
|
838
|
+
var existed = await _removeFromFile(opts.fallbackFile, opts.service, opts.account, opts.passphrase);
|
|
839
|
+
_emit("keychain.removed", existed ? "success" : "no-op", {
|
|
840
|
+
service: opts.service, account: opts.account, backend: "file",
|
|
841
|
+
}, auditOn);
|
|
842
|
+
return existed;
|
|
843
|
+
}
|
|
844
|
+
|
|
845
|
+
async function _removeFromFile(fallbackFile, service, account, passphrase) {
|
|
846
|
+
var doc = await _readFile(fallbackFile, passphrase);
|
|
847
|
+
var bindingKey = _bindingKey(service, account);
|
|
848
|
+
if (!Object.prototype.hasOwnProperty.call(doc.entries, bindingKey)) return false;
|
|
849
|
+
delete doc.entries[bindingKey];
|
|
850
|
+
await _writeFile(fallbackFile, doc, passphrase);
|
|
851
|
+
return true;
|
|
852
|
+
}
|
|
853
|
+
|
|
854
|
+
// ---- Test seam -------------------------------------------------------------
|
|
855
|
+
// Reset the cached backend probe so a test can flip platform / PATH and
|
|
856
|
+
// re-run detection. NOT operator-facing.
|
|
857
|
+
function _clearBackendCacheForTest() { _cachedBackend = null; }
|
|
858
|
+
|
|
859
|
+
module.exports = {
|
|
860
|
+
store: store,
|
|
861
|
+
retrieve: retrieve,
|
|
862
|
+
remove: remove,
|
|
863
|
+
KeychainError: KeychainError,
|
|
864
|
+
_clearBackendCacheForTest: _clearBackendCacheForTest,
|
|
865
|
+
};
|