@tiphareth/dsh-hardssh 0.1.2-alpha → 0.2.2
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 +30 -0
- package/README.md +49 -25
- package/SKILLS.md +140 -0
- package/lib/base/index.js +95 -0
- package/lib/client.js +1165 -1329
- package/lib/fs.js +307 -416
- package/lib/index.js +5428 -2991
- package/lib/ledger-B-LXlftp.js +43 -0
- package/lib/ledger-D2ezq1iW.js +376 -0
- package/lib/model-Cp7f70Mb.js +5 -0
- package/lib/registry-CViuzKYI.js +421 -0
- package/lib/subprocess.js +92 -549
- package/lib/types/backend.d.ts +73 -78
- package/lib/types/base/capability.d.ts +4 -0
- package/lib/types/base/ledger-router.d.ts +42 -26
- package/lib/types/base/ledger.d.ts +30 -19
- package/lib/types/base/model.d.ts +17 -8
- package/lib/types/base/plugin.d.ts +11 -0
- package/lib/types/base/registry.d.ts +28 -31
- package/lib/types/base/router.d.ts +0 -7
- package/lib/types/client/api.d.ts +8 -8
- package/lib/types/client/connect-host.d.ts +13 -0
- package/lib/types/client/index.d.ts +6 -4
- package/lib/types/client/locales.d.ts +15 -1
- package/lib/types/client/session-connect-gate.d.ts +71 -0
- package/lib/types/client/ssh/api.d.ts +11 -9
- package/lib/types/client/ssh/apply.d.ts +14 -8
- package/lib/types/client/ssh/locales.d.ts +8 -1
- package/lib/types/client/ssh/ops-tab.d.ts +34 -0
- package/lib/types/client/ssh/panel/ClusterTab.d.ts +3 -1
- package/lib/types/client/ssh/panel/ConnectionErrorDialog.d.ts +8 -0
- package/lib/types/client/ssh/panel/SessionSecretDialog.d.ts +4 -1
- package/lib/types/client/ssh/panel/SshPanel.d.ts +8 -8
- package/lib/types/client/ssh/panel/TerminalTab.d.ts +3 -5
- package/lib/types/client/ssh/panel/TransferTab.d.ts +5 -1
- package/lib/types/client/ssh/panel/TunnelsTab.d.ts +3 -1
- package/lib/types/client/ssh/session-target.d.ts +17 -0
- package/lib/types/client/state.d.ts +4 -1
- package/lib/types/client/workspace-panel-entry.d.ts +30 -0
- package/lib/types/client/workspace-panel.d.ts +24 -0
- package/lib/types/client-http.d.ts +77 -6
- package/lib/types/core.d.ts +5 -16
- package/lib/types/fs.d.ts +18 -25
- package/lib/types/index.d.ts +138 -9
- package/lib/types/ledger.d.ts +16 -111
- package/lib/types/protocol.d.ts +0 -5
- package/lib/types/providers/index.d.ts +6 -3
- package/lib/types/providers/local/provider.d.ts +83 -23
- package/lib/types/providers/ssh/provider.d.ts +24 -57
- package/lib/types/remote/environment.d.ts +2 -0
- package/lib/types/remote/remote-fs.d.ts +12 -1
- package/lib/types/remote/remote-process.d.ts +22 -1
- package/lib/types/remote/remote-subprocess.d.ts +13 -1
- package/lib/types/remote/remote-terminal.d.ts +4 -0
- package/lib/types/remote-search.d.ts +3 -3
- package/lib/types/routes.d.ts +36 -8
- package/lib/types/runtime/dsh-capabilities.d.ts +18 -0
- package/lib/types/runtime/workspace-core.d.ts +73 -38
- package/lib/types/runtime/workspace-migration.d.ts +113 -0
- package/lib/types/ssh/connection/lease.d.ts +23 -0
- package/lib/types/ssh/connection/manager.d.ts +359 -0
- package/lib/types/ssh/connection/pool.d.ts +8 -1
- package/lib/types/ssh/engine.d.ts +97 -267
- package/lib/types/ssh/known-hosts.d.ts +22 -5
- package/lib/types/ssh/local-transfer-policy.d.ts +20 -0
- package/lib/types/ssh/plugin.d.ts +10 -15
- package/lib/types/ssh/protocol.d.ts +18 -0
- package/lib/types/ssh/routes.d.ts +15 -7
- package/lib/types/ssh/sftp/service.d.ts +216 -0
- package/lib/types/ssh/store.d.ts +20 -8
- package/lib/types/ssh/terminal/service.d.ts +49 -0
- package/lib/types/ssh/tunnel/service.d.ts +26 -0
- package/lib/types/ssh/vault.d.ts +51 -4
- package/lib/types/subprocess.d.ts +13 -21
- package/lib/types/switch/switch-fs.d.ts +52 -2
- package/lib/types/switch/switch-subprocess.d.ts +32 -3
- package/lib/types/tools.d.ts +7 -6
- package/lib/types/workspace-tool-ops.d.ts +33 -0
- package/lib/types/workspace.d.ts +18 -0
- package/lib/vault-3gpWct2Q.js +559 -0
- package/lib/workspace.js +2 -0
- package/package.json +36 -11
- package/scripts/export-legacy-workspaces.mjs +136 -0
- package/src/backend.ts +155 -483
- package/src/base/capability.ts +4 -0
- package/src/base/ledger-router.ts +214 -65
- package/src/base/ledger.ts +175 -48
- package/src/base/model.ts +18 -8
- package/src/base/plugin.ts +15 -4
- package/src/base/registry.ts +49 -41
- package/src/base/router.ts +0 -8
- package/src/client/api.ts +8 -36
- package/src/client/connect-host.ts +199 -0
- package/src/client/directory-flow.tsx +91 -36
- package/src/client/index.ts +56 -53
- package/src/client/locales.ts +14 -0
- package/src/client/session-connect-gate.ts +163 -0
- package/src/client/ssh/api.ts +245 -108
- package/src/client/ssh/apply.ts +23 -24
- package/src/client/ssh/locales.ts +16 -2
- package/src/client/ssh/ops-tab.tsx +79 -0
- package/src/client/ssh/panel/ClusterTab.tsx +9 -20
- package/src/client/ssh/panel/ConnectionErrorDialog.tsx +33 -0
- package/src/client/ssh/panel/SessionSecretDialog.tsx +5 -1
- package/src/client/ssh/panel/SshPanel.tsx +81 -77
- package/src/client/ssh/panel/TerminalTab.tsx +18 -37
- package/src/client/ssh/panel/TransferTab.tsx +43 -33
- package/src/client/ssh/panel/TunnelsTab.tsx +9 -26
- package/src/client/ssh/panel/panel.module.css +90 -127
- package/src/client/ssh/session-target.ts +69 -0
- package/src/client/state.ts +9 -1
- package/src/client/workspace-badges.ts +99 -99
- package/src/client/workspace-panel-entry.tsx +91 -0
- package/src/client/workspace-panel.tsx +227 -0
- package/src/client/workspace.module.css +76 -2
- package/src/client-http.ts +118 -18
- package/src/core.ts +37 -47
- package/src/fs.ts +137 -85
- package/src/index.ts +535 -244
- package/src/ledger.ts +62 -416
- package/src/protocol.ts +0 -6
- package/src/providers/index.ts +8 -4
- package/src/providers/local/provider.ts +259 -87
- package/src/providers/ssh/provider.ts +158 -155
- package/src/remote/environment.ts +22 -1
- package/src/remote/remote-fs.ts +123 -34
- package/src/remote/remote-process.ts +265 -203
- package/src/remote/remote-subprocess.ts +61 -18
- package/src/remote/remote-terminal.ts +106 -21
- package/src/remote-search.ts +122 -26
- package/src/routes.ts +416 -395
- package/src/runtime/dsh-capabilities.ts +19 -0
- package/src/runtime/workspace-core.ts +171 -88
- package/src/runtime/workspace-migration.ts +472 -0
- package/src/ssh/connection/lease.ts +35 -0
- package/src/ssh/connection/manager.ts +1083 -0
- package/src/ssh/connection/pool.ts +341 -275
- package/src/ssh/engine.ts +269 -1477
- package/src/ssh/known-hosts.ts +42 -18
- package/src/ssh/local-transfer-policy.ts +83 -0
- package/src/ssh/plugin.ts +20 -19
- package/src/ssh/protocol.ts +22 -1
- package/src/ssh/routes.ts +419 -187
- package/src/ssh/sftp/service.ts +967 -0
- package/src/ssh/store.ts +169 -72
- package/src/ssh/terminal/service.ts +177 -0
- package/src/ssh/tools.ts +42 -20
- package/src/ssh/tunnel/service.ts +217 -0
- package/src/ssh/vault.ts +245 -91
- package/src/subprocess.ts +88 -71
- package/src/switch/switch-fs.ts +141 -10
- package/src/switch/switch-subprocess.ts +80 -6
- package/src/tools.ts +217 -221
- package/src/workspace-tool-ops.ts +101 -0
- package/src/workspace.ts +52 -0
- package/lib/environment-BL1jddfB.js +0 -449
- package/lib/switch-fs-CAJpFY9C.js +0 -193
- package/lib/switch-fs-RrZtG2gv.js +0 -210
- package/lib/types/client/manager-button.d.ts +0 -32
- package/lib/types/client/ssh/mount.d.ts +0 -13
- package/lib/types/client/ssh/panel/HostsTab.d.ts +0 -10
- package/lib/types/client/ssh/panel/controller.d.ts +0 -23
- package/lib/types/client/ssh/sidebar-entry.d.ts +0 -25
- package/lib/types/client/workspace-gate.d.ts +0 -15
- package/lib/types/remote-runner.d.ts +0 -83
- package/lib/types/seam-state.d.ts +0 -69
- package/src/client/manager-button.tsx +0 -269
- package/src/client/ssh/mount.tsx +0 -83
- package/src/client/ssh/panel/HostsTab.tsx +0 -236
- package/src/client/ssh/panel/controller.ts +0 -48
- package/src/client/ssh/sidebar-entry.ts +0 -123
- package/src/client/workspace-gate.ts +0 -232
- package/src/remote-runner.ts +0 -201
- package/src/seam-state.ts +0 -185
|
@@ -14,15 +14,44 @@ import type { Context } from '@deepseek-ai/cordis';
|
|
|
14
14
|
/** Route one spawn cwd to a runtime. */
|
|
15
15
|
export interface SwitchSubprocessDeps {
|
|
16
16
|
local: SubprocessRuntime;
|
|
17
|
-
/**
|
|
18
|
-
|
|
17
|
+
/**
|
|
18
|
+
* The runtime for a spawn cwd, or `undefined` for a LOCAL cwd.
|
|
19
|
+
*
|
|
20
|
+
* Deliberately `undefined` rather than "return the local runtime": deciding
|
|
21
|
+
* "is this local?" by IDENTITY-comparing the returned service against
|
|
22
|
+
* `deps.local` is not reliable, and getting it wrong made the client-search
|
|
23
|
+
* refusal below fire for LOCAL sessions — which broke glob/grep everywhere
|
|
24
|
+
* (found by real-machine testing). The routing answer is data, so it travels
|
|
25
|
+
* as data.
|
|
26
|
+
*/
|
|
27
|
+
worldFor(cwd: string | undefined): SubprocessRuntime | undefined;
|
|
28
|
+
/** Bare executable names that are CLIENT tools (run locally even in a
|
|
29
|
+
* bound workspace) — e.g. 'pwsh', 'powershell', 'cmd'. Windows-format
|
|
30
|
+
* executables (drive/backslash paths, `*.exe/*.cmd/*.bat/*.ps1`) are
|
|
31
|
+
* detected automatically as client binaries. */
|
|
32
|
+
clientToolNames?: ReadonlyArray<string>;
|
|
19
33
|
}
|
|
20
34
|
/** Workspace-routing subprocess facade. */
|
|
21
35
|
export declare class SwitchSubprocessRuntime extends SubprocessRuntime {
|
|
22
36
|
private readonly deps;
|
|
23
37
|
constructor(ctx: Context, deps: SwitchSubprocessDeps);
|
|
24
|
-
/** The runtime for one spec (by its cwd). */
|
|
38
|
+
/** The runtime for one spec (by its cwd); undefined means LOCAL. */
|
|
25
39
|
private runtimeFor;
|
|
40
|
+
/** Client binaries run on THIS machine even from a bound workspace — their
|
|
41
|
+
* executables cannot exist on the remote POSIX host, and the spawn cwd is
|
|
42
|
+
* the local anchor (which exists locally), so local execution is sound.
|
|
43
|
+
* Everything else runs in the session's world (remote on a bound host).
|
|
44
|
+
*
|
|
45
|
+
* ONE exception is refused rather than routed: a client-side SEARCH helper
|
|
46
|
+
* (rg) in a remote-bound session would search the empty local anchor and
|
|
47
|
+
* report "no matches" — a confidently wrong answer about the server's
|
|
48
|
+
* contents. There is no way to make a local index-of-files tool read the
|
|
49
|
+
* remote workspace, so the caller is told to use the remote tools.
|
|
50
|
+
*
|
|
51
|
+
* Locality comes from the routing answer (`undefined`), never from an
|
|
52
|
+
* identity comparison against `deps.local`: that comparison silently failed
|
|
53
|
+
* once and refused local ripgrep launches for every session. */
|
|
54
|
+
private effectiveRuntime;
|
|
26
55
|
/** @inheritdoc */
|
|
27
56
|
resolveExecutable(command: string, env?: Readonly<Record<string, string>>, signal?: AbortSignal): Promise<string>;
|
|
28
57
|
/** @inheritdoc */
|
package/lib/types/tools.d.ts
CHANGED
|
@@ -1,11 +1,12 @@
|
|
|
1
|
-
import type
|
|
2
|
-
import {
|
|
3
|
-
import type { SshWorkspaceLedger } from './ledger.ts';
|
|
1
|
+
import { type WorkspaceStoreView } from './backend.ts';
|
|
2
|
+
import type { ToolOpsResolver } from './workspace-tool-ops.ts';
|
|
4
3
|
/** Tool-set dependencies. */
|
|
5
4
|
export interface WorkspaceToolsDeps {
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
5
|
+
/** SSH-workspace record source (the generic projection store). */
|
|
6
|
+
workspaces: WorkspaceStoreView;
|
|
7
|
+
/** Bound-workspace operations provider (generic capability seam). Resolves
|
|
8
|
+
* the calling session's cwd to its ops. */
|
|
9
|
+
ops: ToolOpsResolver;
|
|
9
10
|
}
|
|
10
11
|
/** Build every remote_* tool (registered by the host half). */
|
|
11
12
|
export declare function makeWorkspaceTools(deps: WorkspaceToolsDeps): import("@deepseek-ai/dsh-tools").ToolDefinition[];
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bound-workspace operations for the `remote_*` agent tools.
|
|
3
|
+
*
|
|
4
|
+
* The tools resolve the CALLING SESSION's bound workspace from its cwd, then
|
|
5
|
+
* run a small set of operations (directory listing, glob / content-grep)
|
|
6
|
+
* against it. The resolver opens the bound record through WorkspaceCore and
|
|
7
|
+
* uses its `workspace.fs` / `workspace.search` capabilities. It fails closed
|
|
8
|
+
* when the provider omits a required capability rather than falling back to a
|
|
9
|
+
* second engine-backed implementation.
|
|
10
|
+
*/
|
|
11
|
+
import { type WorkspaceStoreView } from './backend.ts';
|
|
12
|
+
import type { SshWorkspaceRecord, WorkspaceEntry } from './protocol.ts';
|
|
13
|
+
import type { WorkspaceCore } from './runtime/workspace-core.ts';
|
|
14
|
+
import type { RemoteGlobResult, RemoteGrepResult } from './remote-search.ts';
|
|
15
|
+
/** Operations one bound workspace exposes to the remote_* tools. */
|
|
16
|
+
export interface WorkspaceToolOps {
|
|
17
|
+
/** List one absolute directory path inside the workspace remote root. */
|
|
18
|
+
listDir(abs: string): Promise<WorkspaceEntry[]>;
|
|
19
|
+
/** glob filename search (pattern keeps glob semantics). */
|
|
20
|
+
glob(pattern: string): Promise<RemoteGlobResult>;
|
|
21
|
+
/** fixed-string content grep (returns matched `path:line:content` records). */
|
|
22
|
+
grep(fixedPhrase: string): Promise<RemoteGrepResult>;
|
|
23
|
+
}
|
|
24
|
+
/** The bound record + its ops for one session cwd. */
|
|
25
|
+
export interface BoundToolOps {
|
|
26
|
+
record: SshWorkspaceRecord;
|
|
27
|
+
ops: WorkspaceToolOps;
|
|
28
|
+
}
|
|
29
|
+
/** Resolve the calling session cwd to a bound workspace, or null when local. */
|
|
30
|
+
export type ToolOpsResolver = (cwd: string | undefined) => Promise<BoundToolOps | null>;
|
|
31
|
+
/** Resolver that opens each bound record through the generic WorkspaceCore. */
|
|
32
|
+
export declare function capabilityToolOpsResolver(store: WorkspaceStoreView, core: WorkspaceCore): ToolOpsResolver;
|
|
33
|
+
//# sourceMappingURL=workspace-tool-ops.d.ts.map
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Public workspace API entry — the stable surface third-party plugins consume.
|
|
3
|
+
* Exports only platform-neutral record/provider/connection/capability types,
|
|
4
|
+
* the `WorkspaceCore` consumer interface, and the stable provider API version.
|
|
5
|
+
*
|
|
6
|
+
* Deliberately NOT exported from here: `SshEngine`, concrete SSH connection /
|
|
7
|
+
* provider classes, the ledger/router/registry internals, and migration or
|
|
8
|
+
* legacy compatibility code — so implementation swaps never leak through this
|
|
9
|
+
* boundary (migration plan Phase 6, §「稳定导出」).
|
|
10
|
+
*
|
|
11
|
+
* @module @tiphareth/dsh-hardssh/workspace
|
|
12
|
+
*/
|
|
13
|
+
export type { ConnectionRef, WorkspaceAnchor, WorkspaceCapabilityMap, WorkspaceConnection, WorkspaceCreateInput, WorkspaceId, WorkspaceLocation, WorkspaceProvider, WorkspaceProviderManifest, WorkspaceProviderRef, WorkspaceRecord, WorkspaceUpdate, } from './base/model.ts';
|
|
14
|
+
export { WORKSPACE_PROVIDER_API_VERSION } from './base/model.ts';
|
|
15
|
+
export type { WorkspaceDirEntry, WorkspaceFileSystem, WorkspaceProcessResult, WorkspaceProcessRuntime, WorkspaceSearchHit, WorkspaceSearchService, WorkspaceStat, } from './base/capability.ts';
|
|
16
|
+
export type { WorkspaceCore, WorkspaceListener, } from './runtime/workspace-core.ts';
|
|
17
|
+
export type { WorkspaceRegistry } from './base/registry.ts';
|
|
18
|
+
//# sourceMappingURL=workspace.d.ts.map
|
|
@@ -0,0 +1,559 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { dirname, join } from "node:path";
|
|
3
|
+
import { homedir } from "node:os";
|
|
4
|
+
import { createCipheriv, createDecipheriv, createHash, randomBytes, scrypt } from "node:crypto";
|
|
5
|
+
//#region src/ssh/vault.ts
|
|
6
|
+
/**
|
|
7
|
+
* Credential vault: secrets (SSH passwords, key passphrases) encrypted at
|
|
8
|
+
* rest with AES-256-GCM under a master key derived from a password via
|
|
9
|
+
* scrypt (Node built-in). Each entry uses an independent random nonce and
|
|
10
|
+
* binds its credential reference as GCM AAD; every ciphertext additionally
|
|
11
|
+
* carries a SHA3-256 digest for integrity self-checks. Brute-force lockout
|
|
12
|
+
* persists across restarts.
|
|
13
|
+
*
|
|
14
|
+
* The vault is deliberately dependency-free (node:crypto + fs only) and has
|
|
15
|
+
* no cordis coupling, matching the HostStore style. KDF parameters are stored
|
|
16
|
+
* in the file header so a later KDF upgrade never invalidates existing
|
|
17
|
+
* vaults (algorithm is a stable switch point).
|
|
18
|
+
*
|
|
19
|
+
* @module dsh-hardssh/vault
|
|
20
|
+
*/
|
|
21
|
+
/** File format version. */
|
|
22
|
+
const FORMAT_VERSION = 1;
|
|
23
|
+
/** Master key length (AES-256). */
|
|
24
|
+
const KEY_BYTES = 32;
|
|
25
|
+
/** AES-GCM nonce length. */
|
|
26
|
+
const NONCE_BYTES = 12;
|
|
27
|
+
/** Password salt length (scrypt). */
|
|
28
|
+
const SALT_BYTES = 16;
|
|
29
|
+
/** scrypt parameters (OWASP-ish baseline; stored per file). */
|
|
30
|
+
const SCRYPT_N = 131072;
|
|
31
|
+
const SCRYPT_R = 8;
|
|
32
|
+
const SCRYPT_P = 1;
|
|
33
|
+
/** Failed-unlock budget before lockout (persisted). */
|
|
34
|
+
const LOCKOUT_THRESHOLD = 5;
|
|
35
|
+
/** Lockout backoff floor (ms). */
|
|
36
|
+
const LOCKOUT_FLOOR_MS = 3e4;
|
|
37
|
+
/** Lockout backoff cap (ms). */
|
|
38
|
+
const LOCKOUT_CAP_MS = 36e5;
|
|
39
|
+
/** Fixed plaintext used to verify the unlock password. */
|
|
40
|
+
const VERIFIER_TEXT = "dsh-hardssh:vault:verify:v1";
|
|
41
|
+
/**
|
|
42
|
+
* Directory holding the credential vault: `<home>/.dsh/ssh-secrets`.
|
|
43
|
+
*
|
|
44
|
+
* It IS inside `~/.dsh` (which the fs seam declares a local-infrastructure root
|
|
45
|
+
* so skills and plugin config stay readable), so its protection does NOT come
|
|
46
|
+
* from location — it comes from `SwitchFileSystem.deniedRoots` (fs.ts), which
|
|
47
|
+
* refuses this directory on every dispatch path, resolve to write, before any
|
|
48
|
+
* backend sees it.
|
|
49
|
+
*
|
|
50
|
+
* Residual risk, stated plainly: a command that runs on THIS machine as the
|
|
51
|
+
* same user (e.g. the client-side `pwsh` tool) can still read the file. What
|
|
52
|
+
* keeps the credential safe is that the file is encrypted (AES-256-GCM +
|
|
53
|
+
* scrypt) AND that environment auto-unlock is off by default — so a stolen
|
|
54
|
+
* ciphertext is only an offline scrypt target, not a usable credential.
|
|
55
|
+
*/
|
|
56
|
+
function vaultDirectory() {
|
|
57
|
+
return join(homedir(), ".dsh", "ssh-secrets");
|
|
58
|
+
}
|
|
59
|
+
/** Vault file location: <home>/.dsh/ssh-secrets/dsh-ssh-vault.json. */
|
|
60
|
+
function vaultPath() {
|
|
61
|
+
return join(vaultDirectory(), "dsh-ssh-vault.json");
|
|
62
|
+
}
|
|
63
|
+
/** Pre-relocation vault location (<home>/.dsh/dsh-ssh-vault.json). */
|
|
64
|
+
function legacyVaultPath() {
|
|
65
|
+
return join(homedir(), ".dsh", "dsh-ssh-vault.json");
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* Move a pre-relocation vault into {@link vaultDirectory} on first use.
|
|
69
|
+
*
|
|
70
|
+
* Never destructive: the new location wins when both exist, and a failed move
|
|
71
|
+
* falls back to copying so the source file is left in place.
|
|
72
|
+
*
|
|
73
|
+
* @param target - destination path (defaults to {@link vaultPath}).
|
|
74
|
+
* @param legacy - source path (defaults to {@link legacyVaultPath}).
|
|
75
|
+
* @returns true when a move/copy happened.
|
|
76
|
+
*/
|
|
77
|
+
function migrateLegacyVault(target = vaultPath(), legacy = legacyVaultPath()) {
|
|
78
|
+
if (target === legacy) return false;
|
|
79
|
+
if (existsSync(target) || !existsSync(legacy)) return false;
|
|
80
|
+
try {
|
|
81
|
+
mkdirSync(dirname(target), {
|
|
82
|
+
recursive: true,
|
|
83
|
+
mode: 448
|
|
84
|
+
});
|
|
85
|
+
try {
|
|
86
|
+
renameSync(legacy, target);
|
|
87
|
+
} catch {
|
|
88
|
+
const contents = readFileSync(legacy);
|
|
89
|
+
writeFileSync(target, contents, { mode: 384 });
|
|
90
|
+
}
|
|
91
|
+
return true;
|
|
92
|
+
} catch {
|
|
93
|
+
return false;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
/** AAD for one entry. */
|
|
97
|
+
function entryAad(purpose, alias) {
|
|
98
|
+
return `dsh-hardssh:v1:${purpose}:${alias}`;
|
|
99
|
+
}
|
|
100
|
+
/** SHA3-256 hex of a UTF-8 string. */
|
|
101
|
+
function sha3_256Hex(text) {
|
|
102
|
+
return createHash("sha3-256").update(text, "utf8").digest("hex");
|
|
103
|
+
}
|
|
104
|
+
/** Errors raised by the vault. */
|
|
105
|
+
var VaultError = class extends Error {
|
|
106
|
+
code;
|
|
107
|
+
constructor(code, message) {
|
|
108
|
+
super(message);
|
|
109
|
+
this.code = code;
|
|
110
|
+
this.name = "VaultError";
|
|
111
|
+
}
|
|
112
|
+
};
|
|
113
|
+
/** Unlocked-password or wrong-password errors. */
|
|
114
|
+
var VaultLockedError = class extends VaultError {
|
|
115
|
+
constructor(message = "vault is locked — unlock it before accessing secrets") {
|
|
116
|
+
super("VAULT_LOCKED", message);
|
|
117
|
+
this.name = "VaultLockedError";
|
|
118
|
+
}
|
|
119
|
+
};
|
|
120
|
+
/** Wrong unlock password (with remaining attempts). */
|
|
121
|
+
var VaultAuthError = class extends VaultError {
|
|
122
|
+
remaining;
|
|
123
|
+
constructor(message, remaining) {
|
|
124
|
+
super("VAULT_AUTH", message);
|
|
125
|
+
this.remaining = remaining;
|
|
126
|
+
this.name = "VaultAuthError";
|
|
127
|
+
}
|
|
128
|
+
};
|
|
129
|
+
/** Locked out due to repeated failures. */
|
|
130
|
+
var VaultLockoutError = class extends VaultError {
|
|
131
|
+
retryAfterMs;
|
|
132
|
+
constructor(message, retryAfterMs) {
|
|
133
|
+
super("VAULT_LOCKOUT", message);
|
|
134
|
+
this.retryAfterMs = retryAfterMs;
|
|
135
|
+
this.name = "VaultLockoutError";
|
|
136
|
+
}
|
|
137
|
+
};
|
|
138
|
+
/**
|
|
139
|
+
* The credential vault. Plain file I/O + node:crypto; no cordis dependency.
|
|
140
|
+
* Construction never requires a password (status/describe work locked); the
|
|
141
|
+
* master key is derived on unlock and cached in memory for the session.
|
|
142
|
+
*/
|
|
143
|
+
var Vault = class {
|
|
144
|
+
path;
|
|
145
|
+
document = emptyDocument();
|
|
146
|
+
unlockedKey;
|
|
147
|
+
lockTimer;
|
|
148
|
+
idleLockMs;
|
|
149
|
+
leaked = /* @__PURE__ */ new Set();
|
|
150
|
+
/** Whether `DSH_CREDENTIAL_PASSWORD` may unlock this instance at load. */
|
|
151
|
+
allowEnvUnlock;
|
|
152
|
+
/** Single-instance mutation queue. Every operation that can change the
|
|
153
|
+
* in-memory document or persist it runs in invocation order. The tail is
|
|
154
|
+
* always recovered so one rejected mutation never poisons later work. */
|
|
155
|
+
mutationTail = Promise.resolve();
|
|
156
|
+
constructor(filePath, options) {
|
|
157
|
+
if (filePath === void 0) migrateLegacyVault();
|
|
158
|
+
this.path = filePath ?? vaultPath();
|
|
159
|
+
this.allowEnvUnlock = options?.allowEnvUnlock === true;
|
|
160
|
+
this.load();
|
|
161
|
+
const env = this.allowEnvUnlock ? process.env.DSH_CREDENTIAL_PASSWORD : void 0;
|
|
162
|
+
if (env !== void 0 && env !== "") this.tryEnvUnlock(env);
|
|
163
|
+
const idle = options?.idleLockMs ?? 18e5;
|
|
164
|
+
if (!Number.isSafeInteger(idle) || idle < 0) throw new VaultError("VAULT_CONFIG", "idleLockMs must be a non-negative safe integer");
|
|
165
|
+
this.idleLockMs = idle;
|
|
166
|
+
}
|
|
167
|
+
load() {
|
|
168
|
+
if (!existsSync(this.path)) {
|
|
169
|
+
this.document = emptyDocument();
|
|
170
|
+
return;
|
|
171
|
+
}
|
|
172
|
+
try {
|
|
173
|
+
const parsed = JSON.parse(readFileSync(this.path, "utf8"));
|
|
174
|
+
if (!isDocument(parsed)) throw new Error("vault shape invalid");
|
|
175
|
+
this.document = parsed;
|
|
176
|
+
} catch {
|
|
177
|
+
try {
|
|
178
|
+
renameSync(this.path, `${this.path}.corrupt-${Date.now()}`);
|
|
179
|
+
} catch {}
|
|
180
|
+
this.document = emptyDocument();
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
save() {
|
|
184
|
+
this.saveDocument(this.document);
|
|
185
|
+
}
|
|
186
|
+
saveDocument(document) {
|
|
187
|
+
const dir = dirname(this.path);
|
|
188
|
+
if (!existsSync(dir)) mkdirSync(dir, {
|
|
189
|
+
recursive: true,
|
|
190
|
+
mode: 448
|
|
191
|
+
});
|
|
192
|
+
const tmp = this.path + `.tmp-${process.pid}-${randomBytes(6).toString("hex")}`;
|
|
193
|
+
try {
|
|
194
|
+
writeFileSync(tmp, JSON.stringify(document, null, 2) + "\n", {
|
|
195
|
+
encoding: "utf8",
|
|
196
|
+
mode: 384
|
|
197
|
+
});
|
|
198
|
+
renameSync(tmp, this.path);
|
|
199
|
+
} catch (error) {
|
|
200
|
+
try {
|
|
201
|
+
unlinkSync(tmp);
|
|
202
|
+
} catch {}
|
|
203
|
+
throw error;
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
deriveKey(password, kdf) {
|
|
207
|
+
return new Promise((resolve, reject) => {
|
|
208
|
+
scrypt(password, Buffer.from(kdf.saltB64, "base64"), KEY_BYTES, {
|
|
209
|
+
N: kdf.N,
|
|
210
|
+
r: kdf.r,
|
|
211
|
+
p: kdf.p,
|
|
212
|
+
maxmem: 536870912
|
|
213
|
+
}, (error, key) => {
|
|
214
|
+
if (error !== null) reject(error);
|
|
215
|
+
else resolve(key);
|
|
216
|
+
});
|
|
217
|
+
});
|
|
218
|
+
}
|
|
219
|
+
lockoutRetryAfter() {
|
|
220
|
+
const doc = this.document;
|
|
221
|
+
if (doc.meta.lockedUntil === 0) return 0;
|
|
222
|
+
const remaining = doc.meta.lockedUntil - Date.now();
|
|
223
|
+
return remaining > 0 ? remaining : 0;
|
|
224
|
+
}
|
|
225
|
+
checkLockout() {
|
|
226
|
+
const doc = this.document;
|
|
227
|
+
if (doc.meta.lockedUntil !== 0) {
|
|
228
|
+
const remaining = this.lockoutRetryAfter();
|
|
229
|
+
if (remaining > 0) throw new VaultLockoutError(`vault locked: too many failed attempts — retry after ${Math.ceil(remaining / 1e3)}s`, remaining);
|
|
230
|
+
doc.meta.attempts = 0;
|
|
231
|
+
doc.meta.lockedUntil = 0;
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
async verifyPassword(key, password) {
|
|
235
|
+
const doc = this.document;
|
|
236
|
+
const previousMeta = { ...doc.meta };
|
|
237
|
+
const persistFailure = () => {
|
|
238
|
+
try {
|
|
239
|
+
this.save();
|
|
240
|
+
} catch (error) {
|
|
241
|
+
doc.meta = previousMeta;
|
|
242
|
+
throw error;
|
|
243
|
+
}
|
|
244
|
+
};
|
|
245
|
+
const verifier = doc.verifier;
|
|
246
|
+
try {
|
|
247
|
+
const plain = this.decryptEntryBuffer(key, verifier.aad, verifier.nonceB64, verifier.ciphertextB64, verifier.tagB64);
|
|
248
|
+
if (plain.toString("utf8") !== VERIFIER_TEXT) throw new Error("verifier mismatch");
|
|
249
|
+
plain.fill(0);
|
|
250
|
+
} catch {
|
|
251
|
+
doc.meta.attempts += 1;
|
|
252
|
+
if (doc.meta.attempts >= LOCKOUT_THRESHOLD) {
|
|
253
|
+
const backoff = backoffFor(doc.meta.attempts);
|
|
254
|
+
doc.meta.lockedUntil = Date.now() + backoff;
|
|
255
|
+
persistFailure();
|
|
256
|
+
throw new VaultLockoutError(`vault locked after ${doc.meta.attempts} failed attempts — retry after ${Math.ceil(backoff / 1e3)}s`, backoff);
|
|
257
|
+
}
|
|
258
|
+
persistFailure();
|
|
259
|
+
throw new VaultAuthError(`wrong vault password (${doc.meta.attempts}/${LOCKOUT_THRESHOLD} attempts used)`, LOCKOUT_THRESHOLD - doc.meta.attempts);
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
encryptEntryBuffer(key, aad, plaintext) {
|
|
263
|
+
const nonce = randomBytes(NONCE_BYTES);
|
|
264
|
+
const cipher = createCipheriv("aes-256-gcm", key, nonce);
|
|
265
|
+
cipher.setAAD(Buffer.from(aad, "utf8"));
|
|
266
|
+
const ciphertext = Buffer.concat([cipher.update(plaintext), cipher.final()]);
|
|
267
|
+
const tag = cipher.getAuthTag();
|
|
268
|
+
return {
|
|
269
|
+
nonceB64: nonce.toString("base64"),
|
|
270
|
+
ciphertextB64: ciphertext.toString("base64"),
|
|
271
|
+
tagB64: tag.toString("base64")
|
|
272
|
+
};
|
|
273
|
+
}
|
|
274
|
+
decryptEntryBuffer(key, aad, nonceB64, ciphertextB64, tagB64) {
|
|
275
|
+
const nonce = Buffer.from(nonceB64, "base64");
|
|
276
|
+
const tag = Buffer.from(tagB64, "base64");
|
|
277
|
+
const ciphertext = Buffer.from(ciphertextB64, "base64");
|
|
278
|
+
const decipher = createDecipheriv("aes-256-gcm", key, nonce);
|
|
279
|
+
decipher.setAAD(Buffer.from(aad, "utf8"));
|
|
280
|
+
decipher.setAuthTag(tag);
|
|
281
|
+
return Buffer.concat([decipher.update(ciphertext), decipher.final()]);
|
|
282
|
+
}
|
|
283
|
+
requireKey() {
|
|
284
|
+
if (this.unlockedKey === void 0) throw new VaultLockedError();
|
|
285
|
+
return this.unlockedKey;
|
|
286
|
+
}
|
|
287
|
+
validateMasterPassword(password) {
|
|
288
|
+
if (password.trim().length < 8) throw new VaultError("VAULT_PASSWORD_WEAK", `vault password must contain at least 8 non-whitespace characters`);
|
|
289
|
+
}
|
|
290
|
+
/** Successful credential activity extends the idle lock deadline. */
|
|
291
|
+
touch() {
|
|
292
|
+
if (this.lockTimer !== void 0) clearTimeout(this.lockTimer);
|
|
293
|
+
this.lockTimer = void 0;
|
|
294
|
+
if (this.unlockedKey === void 0 || this.idleLockMs === 0) return;
|
|
295
|
+
this.lockTimer = setTimeout(() => {
|
|
296
|
+
this.lock();
|
|
297
|
+
}, this.idleLockMs);
|
|
298
|
+
this.lockTimer.unref?.();
|
|
299
|
+
}
|
|
300
|
+
enqueueMutation(mutation) {
|
|
301
|
+
const result = this.mutationTail.then(mutation);
|
|
302
|
+
this.mutationTail = result.then(() => void 0, () => void 0);
|
|
303
|
+
return result;
|
|
304
|
+
}
|
|
305
|
+
async tryEnvUnlock(password) {
|
|
306
|
+
try {
|
|
307
|
+
await this.unlock(password);
|
|
308
|
+
} catch {}
|
|
309
|
+
}
|
|
310
|
+
/** Current status (locked / mode / entry count) — safe for UIs. */
|
|
311
|
+
status() {
|
|
312
|
+
const hasEntries = this.document.secrets.length > 0;
|
|
313
|
+
return {
|
|
314
|
+
locked: this.unlockedKey === void 0 || hasEntries === false && this.document.verifier.ciphertextB64 === "",
|
|
315
|
+
mode: this.allowEnvUnlock && process.env.DSH_CREDENTIAL_PASSWORD !== void 0 ? "env" : "password",
|
|
316
|
+
entries: this.document.secrets.length
|
|
317
|
+
};
|
|
318
|
+
}
|
|
319
|
+
/** Unlock with a password (loopback route / env). Persists lockout on failure. */
|
|
320
|
+
async unlock(password) {
|
|
321
|
+
return this.enqueueMutation(async () => {
|
|
322
|
+
this.validateMasterPassword(password);
|
|
323
|
+
if (this.unlockedKey !== void 0) {
|
|
324
|
+
this.touch();
|
|
325
|
+
return;
|
|
326
|
+
}
|
|
327
|
+
this.checkLockout();
|
|
328
|
+
const doc = this.document;
|
|
329
|
+
if (doc.verifier.ciphertextB64 === "") {
|
|
330
|
+
const salt = randomBytes(SALT_BYTES);
|
|
331
|
+
const kdf = {
|
|
332
|
+
algorithm: "scrypt",
|
|
333
|
+
N: SCRYPT_N,
|
|
334
|
+
r: SCRYPT_R,
|
|
335
|
+
p: SCRYPT_P,
|
|
336
|
+
saltB64: salt.toString("base64")
|
|
337
|
+
};
|
|
338
|
+
const key = await this.deriveKey(password, kdf);
|
|
339
|
+
const box = this.encryptEntryBuffer(key, entryAad("host.password", "vault-verify"), Buffer.from(VERIFIER_TEXT, "utf8"));
|
|
340
|
+
const next = {
|
|
341
|
+
...doc,
|
|
342
|
+
kdf,
|
|
343
|
+
verifier: {
|
|
344
|
+
...box,
|
|
345
|
+
aad: entryAad("host.password", "vault-verify")
|
|
346
|
+
}
|
|
347
|
+
};
|
|
348
|
+
try {
|
|
349
|
+
this.saveDocument(next);
|
|
350
|
+
} catch (error) {
|
|
351
|
+
key.fill(0);
|
|
352
|
+
throw error;
|
|
353
|
+
}
|
|
354
|
+
this.document = next;
|
|
355
|
+
this.unlockedKey = key;
|
|
356
|
+
this.touch();
|
|
357
|
+
return;
|
|
358
|
+
}
|
|
359
|
+
const key = await this.deriveKey(password, doc.kdf);
|
|
360
|
+
try {
|
|
361
|
+
await this.verifyPassword(key, password);
|
|
362
|
+
} catch (error) {
|
|
363
|
+
key.fill(0);
|
|
364
|
+
throw error;
|
|
365
|
+
}
|
|
366
|
+
this.unlockedKey = key;
|
|
367
|
+
this.touch();
|
|
368
|
+
});
|
|
369
|
+
}
|
|
370
|
+
/** Lock: drop the in-memory key. JS strings cannot be zeroized; the Buffer
|
|
371
|
+
* key is wiped. */
|
|
372
|
+
lock() {
|
|
373
|
+
if (this.lockTimer !== void 0) clearTimeout(this.lockTimer);
|
|
374
|
+
this.lockTimer = void 0;
|
|
375
|
+
if (this.unlockedKey !== void 0) {
|
|
376
|
+
this.unlockedKey.fill(0);
|
|
377
|
+
this.unlockedKey = void 0;
|
|
378
|
+
}
|
|
379
|
+
this.leaked.clear();
|
|
380
|
+
}
|
|
381
|
+
/** Store one secret, returning a random ref. Requires unlock. */
|
|
382
|
+
async store(purpose, alias, secret) {
|
|
383
|
+
return this.enqueueMutation(() => {
|
|
384
|
+
const key = this.requireKey();
|
|
385
|
+
const ref = "vlt_" + randomBytes(16).toString("hex");
|
|
386
|
+
const aad = entryAad(purpose, alias);
|
|
387
|
+
const box = this.encryptEntryBuffer(key, aad, Buffer.from(secret, "utf8"));
|
|
388
|
+
const data = `${box.nonceB64}${box.ciphertextB64}${box.tagB64}`;
|
|
389
|
+
const entry = {
|
|
390
|
+
ref,
|
|
391
|
+
purpose,
|
|
392
|
+
alias,
|
|
393
|
+
...box,
|
|
394
|
+
aad,
|
|
395
|
+
sha3: sha3_256Hex(data)
|
|
396
|
+
};
|
|
397
|
+
this.document.secrets.push(entry);
|
|
398
|
+
try {
|
|
399
|
+
this.save();
|
|
400
|
+
} catch (error) {
|
|
401
|
+
this.document.secrets.pop();
|
|
402
|
+
throw error;
|
|
403
|
+
}
|
|
404
|
+
this.touch();
|
|
405
|
+
return ref;
|
|
406
|
+
});
|
|
407
|
+
}
|
|
408
|
+
/** Reveal one secret (unlock required); registers it for redaction. */
|
|
409
|
+
async reveal(ref) {
|
|
410
|
+
const key = this.requireKey();
|
|
411
|
+
const entry = this.document.secrets.find((candidate) => candidate.ref === ref);
|
|
412
|
+
if (entry === void 0) throw new VaultError("VAULT_MISSING", `no secret for ref '${ref}'`);
|
|
413
|
+
if (sha3_256Hex(`${entry.nonceB64}${entry.ciphertextB64}${entry.tagB64}`) !== entry.sha3) throw new VaultError("VAULT_CORRUPTED", `entry '${ref}' failed its SHA3-256 integrity check`);
|
|
414
|
+
const plain = this.decryptEntryBuffer(key, entry.aad, entry.nonceB64, entry.ciphertextB64, entry.tagB64);
|
|
415
|
+
const text = plain.toString("utf8");
|
|
416
|
+
plain.fill(0);
|
|
417
|
+
this.leaked.add(text);
|
|
418
|
+
this.touch();
|
|
419
|
+
return text;
|
|
420
|
+
}
|
|
421
|
+
/** Delete one secret (host delete / cleanup). */
|
|
422
|
+
async remove(ref) {
|
|
423
|
+
return this.enqueueMutation(() => {
|
|
424
|
+
this.requireKey();
|
|
425
|
+
const before = this.document.secrets;
|
|
426
|
+
const next = before.filter((entry) => entry.ref !== ref);
|
|
427
|
+
if (next.length !== before.length) {
|
|
428
|
+
this.document.secrets = next;
|
|
429
|
+
try {
|
|
430
|
+
this.save();
|
|
431
|
+
} catch (error) {
|
|
432
|
+
this.document.secrets = before;
|
|
433
|
+
throw error;
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
this.touch();
|
|
437
|
+
});
|
|
438
|
+
}
|
|
439
|
+
/** Rekey with a new password: decrypt all, re-encrypt under a fresh KDF. */
|
|
440
|
+
async rekey(newPassword) {
|
|
441
|
+
return this.enqueueMutation(async () => {
|
|
442
|
+
this.validateMasterPassword(newPassword);
|
|
443
|
+
const key = this.requireKey();
|
|
444
|
+
const doc = this.document;
|
|
445
|
+
const plaintexts = [];
|
|
446
|
+
for (const entry of doc.secrets) {
|
|
447
|
+
const plain = this.decryptEntryBuffer(key, entry.aad, entry.nonceB64, entry.ciphertextB64, entry.tagB64);
|
|
448
|
+
plaintexts.push({
|
|
449
|
+
purpose: entry.purpose,
|
|
450
|
+
alias: entry.alias,
|
|
451
|
+
value: plain.toString("utf8")
|
|
452
|
+
});
|
|
453
|
+
plain.fill(0);
|
|
454
|
+
}
|
|
455
|
+
const salt = randomBytes(SALT_BYTES);
|
|
456
|
+
const kdf = {
|
|
457
|
+
algorithm: "scrypt",
|
|
458
|
+
N: SCRYPT_N,
|
|
459
|
+
r: SCRYPT_R,
|
|
460
|
+
p: SCRYPT_P,
|
|
461
|
+
saltB64: salt.toString("base64")
|
|
462
|
+
};
|
|
463
|
+
const newKey = await this.deriveKey(newPassword, kdf);
|
|
464
|
+
try {
|
|
465
|
+
const box = this.encryptEntryBuffer(newKey, entryAad("host.password", "vault-verify"), Buffer.from(VERIFIER_TEXT, "utf8"));
|
|
466
|
+
const secrets = plaintexts.map(({ purpose, alias, value }, index) => {
|
|
467
|
+
const aad = entryAad(purpose, alias);
|
|
468
|
+
const b = this.encryptEntryBuffer(newKey, aad, Buffer.from(value, "utf8"));
|
|
469
|
+
const data = `${b.nonceB64}${b.ciphertextB64}${b.tagB64}`;
|
|
470
|
+
return {
|
|
471
|
+
ref: rekeyOriginalRef(doc.secrets[index]),
|
|
472
|
+
purpose,
|
|
473
|
+
alias,
|
|
474
|
+
...b,
|
|
475
|
+
aad,
|
|
476
|
+
sha3: sha3_256Hex(data)
|
|
477
|
+
};
|
|
478
|
+
});
|
|
479
|
+
const next = {
|
|
480
|
+
...doc,
|
|
481
|
+
kdf,
|
|
482
|
+
verifier: {
|
|
483
|
+
...box,
|
|
484
|
+
aad: entryAad("host.password", "vault-verify")
|
|
485
|
+
},
|
|
486
|
+
meta: {
|
|
487
|
+
attempts: 0,
|
|
488
|
+
lockedUntil: 0
|
|
489
|
+
},
|
|
490
|
+
secrets
|
|
491
|
+
};
|
|
492
|
+
this.saveDocument(next);
|
|
493
|
+
key.fill(0);
|
|
494
|
+
this.document = next;
|
|
495
|
+
this.unlockedKey = newKey;
|
|
496
|
+
this.touch();
|
|
497
|
+
} catch (error) {
|
|
498
|
+
newKey.fill(0);
|
|
499
|
+
throw error;
|
|
500
|
+
} finally {
|
|
501
|
+
for (const item of plaintexts) item.value = "";
|
|
502
|
+
}
|
|
503
|
+
});
|
|
504
|
+
}
|
|
505
|
+
/** Redact every leaked secret value in `text` (Leak Guard light). */
|
|
506
|
+
redact(text) {
|
|
507
|
+
if (this.leaked.size === 0) return text;
|
|
508
|
+
let out = text;
|
|
509
|
+
for (const value of this.leaked) if (value.length > 0) out = out.split(value).join("[REDACTED]");
|
|
510
|
+
return out;
|
|
511
|
+
}
|
|
512
|
+
/** Dispose: wipe the key and stop the idle timer. */
|
|
513
|
+
dispose() {
|
|
514
|
+
if (this.lockTimer !== void 0) clearTimeout(this.lockTimer);
|
|
515
|
+
this.lock();
|
|
516
|
+
}
|
|
517
|
+
};
|
|
518
|
+
/** Empty vault document. */
|
|
519
|
+
function emptyDocument() {
|
|
520
|
+
return {
|
|
521
|
+
version: FORMAT_VERSION,
|
|
522
|
+
kdf: {
|
|
523
|
+
algorithm: "scrypt",
|
|
524
|
+
N: SCRYPT_N,
|
|
525
|
+
r: SCRYPT_R,
|
|
526
|
+
p: SCRYPT_P,
|
|
527
|
+
saltB64: ""
|
|
528
|
+
},
|
|
529
|
+
verifier: {
|
|
530
|
+
nonceB64: "",
|
|
531
|
+
ciphertextB64: "",
|
|
532
|
+
tagB64: "",
|
|
533
|
+
aad: ""
|
|
534
|
+
},
|
|
535
|
+
meta: {
|
|
536
|
+
attempts: 0,
|
|
537
|
+
lockedUntil: 0
|
|
538
|
+
},
|
|
539
|
+
secrets: []
|
|
540
|
+
};
|
|
541
|
+
}
|
|
542
|
+
/** Lockout backoff: exponential, floored, capped. */
|
|
543
|
+
function backoffFor(attempts) {
|
|
544
|
+
const exponent = Math.min(attempts - LOCKOUT_THRESHOLD, 6);
|
|
545
|
+
const backoff = LOCKOUT_FLOOR_MS * Math.pow(2, exponent);
|
|
546
|
+
return Math.min(backoff, LOCKOUT_CAP_MS);
|
|
547
|
+
}
|
|
548
|
+
/** Guard: a parsed JSON value is a valid vault document. */
|
|
549
|
+
function isDocument(value) {
|
|
550
|
+
if (typeof value !== "object" || value === null) return false;
|
|
551
|
+
const doc = value;
|
|
552
|
+
return typeof doc.version === "number" && typeof doc.kdf === "object" && doc.kdf !== null && typeof doc.kdf.algorithm === "string" && typeof doc.verifier === "object" && doc.verifier !== null && typeof doc.meta === "object" && doc.meta !== null && Array.isArray(doc.secrets);
|
|
553
|
+
}
|
|
554
|
+
/** The ref to reuse for the index-th entry during rekey (preserves identity). */
|
|
555
|
+
function rekeyOriginalRef(entry) {
|
|
556
|
+
return entry?.ref ?? "vlt_" + randomBytes(16).toString("hex");
|
|
557
|
+
}
|
|
558
|
+
//#endregion
|
|
559
|
+
export { legacyVaultPath as n, vaultDirectory as r, Vault as t };
|
package/lib/workspace.js
ADDED