tapline-host 1.6.2 → 1.7.0
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/dist/accounts.d.ts +129 -12
- package/dist/accounts.js +844 -75
- package/dist/accounts.js.map +1 -1
- package/dist/actionlog.d.ts +11 -4
- package/dist/actionlog.js +22 -1
- package/dist/actionlog.js.map +1 -1
- package/dist/animations.d.ts +4 -2
- package/dist/animations.js +7 -7
- package/dist/animations.js.map +1 -1
- package/dist/api.d.ts +7 -1
- package/dist/api.js +12 -1
- package/dist/api.js.map +1 -1
- package/dist/audit.d.ts +1 -0
- package/dist/auditlog.d.ts +40 -0
- package/dist/auditlog.js +150 -0
- package/dist/auditlog.js.map +1 -0
- package/dist/cli.d.ts +8 -0
- package/dist/cli.js +143 -7
- package/dist/cli.js.map +1 -1
- package/dist/control.d.ts +51 -0
- package/dist/control.js +110 -0
- package/dist/control.js.map +1 -1
- package/dist/doctor.d.ts +6 -0
- package/dist/doctor.js +61 -4
- package/dist/doctor.js.map +1 -1
- package/dist/drive.d.ts +5 -2
- package/dist/drive.js +50 -13
- package/dist/drive.js.map +1 -1
- package/dist/elicit.d.ts +47 -0
- package/dist/elicit.js +87 -0
- package/dist/elicit.js.map +1 -1
- package/dist/format.d.ts +6 -0
- package/dist/format.js +25 -4
- package/dist/format.js.map +1 -1
- package/dist/grants.d.ts +297 -0
- package/dist/grants.js +938 -0
- package/dist/grants.js.map +1 -0
- package/dist/grantscmd.d.ts +34 -0
- package/dist/grantscmd.js +100 -0
- package/dist/grantscmd.js.map +1 -0
- package/dist/hostfacts.d.ts +23 -0
- package/dist/hostfacts.js +65 -0
- package/dist/hostfacts.js.map +1 -0
- package/dist/inspect.d.ts +3 -2
- package/dist/inspect.js +20 -8
- package/dist/inspect.js.map +1 -1
- package/dist/mapcmd.d.ts +2 -2
- package/dist/mcp.js +1 -1
- package/dist/nochange.d.ts +1 -0
- package/dist/otpclient.d.ts +79 -0
- package/dist/otpclient.js +362 -0
- package/dist/otpclient.js.map +1 -0
- package/dist/pidlock.d.ts +99 -0
- package/dist/pidlock.js +382 -0
- package/dist/pidlock.js.map +1 -0
- package/dist/places.d.ts +11 -0
- package/dist/places.js +28 -2
- package/dist/places.js.map +1 -1
- package/dist/policy.d.ts +2 -1
- package/dist/policy.js +5 -2
- package/dist/policy.js.map +1 -1
- package/dist/protocol.d.ts +4 -0
- package/dist/protocol.js +2 -0
- package/dist/protocol.js.map +1 -1
- package/dist/report.d.ts +8 -0
- package/dist/report.js +34 -1
- package/dist/report.js.map +1 -1
- package/dist/routes.d.ts +1 -1
- package/dist/routes.js +27 -11
- package/dist/routes.js.map +1 -1
- package/dist/runschedule.d.ts +119 -0
- package/dist/runschedule.js +281 -0
- package/dist/runschedule.js.map +1 -0
- package/dist/runtime.d.ts +48 -1
- package/dist/runtime.js +214 -18
- package/dist/runtime.js.map +1 -1
- package/dist/schedule.d.ts +145 -0
- package/dist/schedule.js +401 -0
- package/dist/schedule.js.map +1 -0
- package/dist/schedulehost.d.ts +73 -0
- package/dist/schedulehost.js +391 -0
- package/dist/schedulehost.js.map +1 -0
- package/dist/script.d.ts +2 -0
- package/dist/script.js +2 -0
- package/dist/script.js.map +1 -1
- package/dist/server.d.ts +6 -0
- package/dist/server.js +3 -1
- package/dist/server.js.map +1 -1
- package/dist/thincall.d.ts +13 -0
- package/dist/thincall.js +139 -59
- package/dist/thincall.js.map +1 -1
- package/dist/tool-text-data.d.ts +1 -1
- package/dist/tool-text-data.js +1 -1
- package/dist/tool-text-data.js.map +1 -1
- package/dist/tool-text.d.ts +1 -1
- package/dist/tools.d.ts +116 -6
- package/dist/tools.js +758 -134
- package/dist/tools.js.map +1 -1
- package/dist/unlockcmd.d.ts +44 -0
- package/dist/unlockcmd.js +80 -0
- package/dist/unlockcmd.js.map +1 -0
- package/dist/uxcheck.d.ts +1 -0
- package/dist/vault.d.ts +75 -0
- package/dist/vault.js +395 -0
- package/dist/vault.js.map +1 -0
- package/package.json +1 -1
- package/skills/tapline/SKILL.md +34 -6
- package/skills/tapline/references/setup.md +11 -1
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import type { AuditSink } from "./auditlog.js";
|
|
2
|
+
import { type PidLockDeps } from "./pidlock.js";
|
|
3
|
+
/**
|
|
4
|
+
* AGENT-TOOLS 1.14.2 §22.3 and §19.5: the way out of a stuck lock. The pid-liveness lock never takes over an empty or unreadable
|
|
5
|
+
* lock (that can only be a holder that crashed mid-write where hard links aren't available), so a person looks and removes it:
|
|
6
|
+
* `tapline schedule unlock NAME` and `tapline grants unlock --policy FILE`. Both remove a lock (and its `.takeover`) only when no
|
|
7
|
+
* holder is proven alive, and write an audit line (§23).
|
|
8
|
+
*/
|
|
9
|
+
type Outcome = {
|
|
10
|
+
text: string;
|
|
11
|
+
code: number;
|
|
12
|
+
};
|
|
13
|
+
/** A schedule's run lock: `<config>/schedules/<name>.lock`. */
|
|
14
|
+
export declare function scheduleLockPath(configDir: string, name: string): string;
|
|
15
|
+
/** `tapline schedule unlock NAME`. Audit: a `schedule` line (§23). */
|
|
16
|
+
export declare function scheduleUnlock(name: string | undefined, options: {
|
|
17
|
+
configDir: string;
|
|
18
|
+
audit: AuditSink;
|
|
19
|
+
deps: PidLockDeps;
|
|
20
|
+
}): Outcome;
|
|
21
|
+
/**
|
|
22
|
+
* `tapline grants unlock --policy FILE`. Audit: an `approval` line. It is a person's own decision about the policy file, not a
|
|
23
|
+
* grant's use and not a revocation of any grant (§23's `revoke` is a grant revoked), so `approval` fits best.
|
|
24
|
+
*/
|
|
25
|
+
export declare function grantsUnlock(policy: string | undefined, options: {
|
|
26
|
+
audit: AuditSink;
|
|
27
|
+
deps: PidLockDeps;
|
|
28
|
+
}): Outcome;
|
|
29
|
+
export interface StuckLock {
|
|
30
|
+
/** The lock file (or `.takeover`) that is stuck. */
|
|
31
|
+
path: string;
|
|
32
|
+
/** Why: empty, unreadable, or held by a dead process. */
|
|
33
|
+
why: string;
|
|
34
|
+
/** The command that removes it. */
|
|
35
|
+
command: string;
|
|
36
|
+
}
|
|
37
|
+
export declare const quote: (text: string) => string;
|
|
38
|
+
/** Every stuck lock doctor can see: the schedule run locks always, the policy edit lock when a policy file is given. */
|
|
39
|
+
export declare function findStuckLocks(options: {
|
|
40
|
+
configDir: string;
|
|
41
|
+
policyFile?: string;
|
|
42
|
+
deps: PidLockDeps;
|
|
43
|
+
}): StuckLock[];
|
|
44
|
+
export {};
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { readdirSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { policyEditLockPath } from "./grants.js";
|
|
4
|
+
import { inspectPidLock, stuckWords, unlockPidLock } from "./pidlock.js";
|
|
5
|
+
import { SCHEDULE_NAME, schedulesDir } from "./schedule.js";
|
|
6
|
+
/** A schedule's run lock: `<config>/schedules/<name>.lock`. */
|
|
7
|
+
export function scheduleLockPath(configDir, name) {
|
|
8
|
+
return join(schedulesDir(configDir), `${name}.lock`);
|
|
9
|
+
}
|
|
10
|
+
function describe(what, result) {
|
|
11
|
+
if (result.status === "none")
|
|
12
|
+
return { text: `${what} has no lock; nothing to remove.`, code: 0 };
|
|
13
|
+
if (result.status === "live") {
|
|
14
|
+
return {
|
|
15
|
+
text: `${what}'s lock is held by a live process (pid ${result.pid}); nothing was removed. Wait for it to finish, or stop that process first.`,
|
|
16
|
+
code: 1,
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
if (result.status === "failed") {
|
|
20
|
+
const done = result.removed.map((r) => `Removed ${r.path} (${r.why}).`);
|
|
21
|
+
return { text: [...done, `Couldn't remove ${result.path ?? "the lock"}: it changed or can't be deleted. Look at it and try again.`].join("\n"), code: 1 };
|
|
22
|
+
}
|
|
23
|
+
return { text: result.removed.map((r) => `Removed ${r.path} (${r.why}).`).join("\n"), code: 0 };
|
|
24
|
+
}
|
|
25
|
+
/** `tapline schedule unlock NAME`. Audit: a `schedule` line (§23). */
|
|
26
|
+
export function scheduleUnlock(name, options) {
|
|
27
|
+
if (name === undefined)
|
|
28
|
+
return { text: "Usage: tapline schedule unlock NAME", code: 2 };
|
|
29
|
+
if (!SCHEDULE_NAME.test(name))
|
|
30
|
+
return { text: `"${name}" isn't a schedule's name.`, code: 2 };
|
|
31
|
+
const result = unlockPidLock(options.deps, scheduleLockPath(options.configDir, name));
|
|
32
|
+
if (result.status === "removed" || result.status === "failed") {
|
|
33
|
+
if (result.removed.length > 0)
|
|
34
|
+
options.audit.write({ kind: "schedule", name, detail: `unlocked (${result.removed.map((r) => r.why).join("; ")})` });
|
|
35
|
+
}
|
|
36
|
+
return describe(`Schedule "${name}"`, result);
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* `tapline grants unlock --policy FILE`. Audit: an `approval` line. It is a person's own decision about the policy file, not a
|
|
40
|
+
* grant's use and not a revocation of any grant (§23's `revoke` is a grant revoked), so `approval` fits best.
|
|
41
|
+
*/
|
|
42
|
+
export function grantsUnlock(policy, options) {
|
|
43
|
+
if (policy === undefined)
|
|
44
|
+
return { text: "grants unlock needs the policy file: tapline grants unlock --policy FILE", code: 2 };
|
|
45
|
+
const result = unlockPidLock(options.deps, policyEditLockPath(policy));
|
|
46
|
+
if (result.removed.length > 0)
|
|
47
|
+
options.audit.write({ kind: "approval", name: "policy edit lock", detail: `unlocked (${result.removed.map((r) => r.why).join("; ")})` });
|
|
48
|
+
return describe("The policy file", result);
|
|
49
|
+
}
|
|
50
|
+
export const quote = (text) => (/^[\w./\\:@=+-]+$/.test(text) ? text : `"${text.replace(/"/g, '\\"')}"`);
|
|
51
|
+
/** Every stuck lock doctor can see: the schedule run locks always, the policy edit lock when a policy file is given. */
|
|
52
|
+
export function findStuckLocks(options) {
|
|
53
|
+
const found = [];
|
|
54
|
+
let files = [];
|
|
55
|
+
try {
|
|
56
|
+
files = readdirSync(schedulesDir(options.configDir)).sort();
|
|
57
|
+
}
|
|
58
|
+
catch {
|
|
59
|
+
// no schedules folder: no run locks
|
|
60
|
+
}
|
|
61
|
+
for (const file of files) {
|
|
62
|
+
const match = /^(.+)\.lock(\.takeover)?$/.exec(file);
|
|
63
|
+
const name = match?.[1];
|
|
64
|
+
if (name === undefined || !SCHEDULE_NAME.test(name))
|
|
65
|
+
continue;
|
|
66
|
+
const path = join(schedulesDir(options.configDir), file);
|
|
67
|
+
const state = inspectPidLock(options.deps, path);
|
|
68
|
+
if (state.state === "stuck")
|
|
69
|
+
found.push({ path, why: stuckWords(state), command: `tapline schedule unlock ${name}` });
|
|
70
|
+
}
|
|
71
|
+
if (options.policyFile !== undefined) {
|
|
72
|
+
for (const path of [policyEditLockPath(options.policyFile), `${policyEditLockPath(options.policyFile)}.takeover`]) {
|
|
73
|
+
const state = inspectPidLock(options.deps, path);
|
|
74
|
+
if (state.state === "stuck")
|
|
75
|
+
found.push({ path, why: stuckWords(state), command: `tapline grants unlock --policy ${quote(options.policyFile)}` });
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
return found;
|
|
79
|
+
}
|
|
80
|
+
//# sourceMappingURL=unlockcmd.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"unlockcmd.js","sourceRoot":"","sources":["../src/unlockcmd.ts"],"names":[],"mappings":"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"}
|
package/dist/uxcheck.d.ts
CHANGED
|
@@ -53,6 +53,7 @@ export declare const uxResultSchema: z.ZodObject<{
|
|
|
53
53
|
selected: z.ZodOptional<z.ZodNullable<z.ZodBoolean>>;
|
|
54
54
|
checked: z.ZodOptional<z.ZodNullable<z.ZodUnion<readonly [z.ZodBoolean, z.ZodString]>>>;
|
|
55
55
|
row: z.ZodOptional<z.ZodNullable<z.ZodBoolean>>;
|
|
56
|
+
fromOthers: z.ZodOptional<z.ZodNullable<z.ZodBoolean>>;
|
|
56
57
|
}, z.core.$strip>>>;
|
|
57
58
|
where: z.ZodOptional<z.ZodObject<{
|
|
58
59
|
frame: z.ZodOptional<z.ZodObject<{
|
package/dist/vault.d.ts
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* AGENT-TOOLS 1.14 §20.2: where the vault is kept. One format for both tools:
|
|
3
|
+
* - Windows: `{"version": 2, "protected": "dpapi-user", "data": <base64 of CryptProtectData(UTF-8 JSON)>}`, current-user scope,
|
|
4
|
+
* with the fixed entropy below. Tapline calls DPAPI through PowerShell, Tapwright through ctypes; the blob is the same.
|
|
5
|
+
* - macOS: a keychain generic password (service below, account = the file's real path), the same UTF-8 JSON as its data;
|
|
6
|
+
* the file holds `{"version": 2, "protected": "keychain"}`.
|
|
7
|
+
* - Plain: `{"version": 2, "vault": {...}}`, for test data only.
|
|
8
|
+
* Nothing here ever quotes a value: a failure says why in words.
|
|
9
|
+
*/
|
|
10
|
+
/** The optional entropy of the DPAPI blob (§20.2). */
|
|
11
|
+
export declare const DPAPI_ENTROPY: Buffer<ArrayBuffer>;
|
|
12
|
+
/** The keychain service (§20.2). */
|
|
13
|
+
export declare const KEYCHAIN_SERVICE = "tapline-tapwright-vault";
|
|
14
|
+
export type Protection = "dpapi-user" | "keychain";
|
|
15
|
+
/** What the person is told at every start while the vault is a plain file (§20.2). */
|
|
16
|
+
export declare function plainWarning(path: string): string;
|
|
17
|
+
export interface ProcResult {
|
|
18
|
+
code: number | null;
|
|
19
|
+
stdout: string;
|
|
20
|
+
stderr: string;
|
|
21
|
+
}
|
|
22
|
+
/** Runs a program with text on its stdin. The text may hold a secret: it never goes on the command line. */
|
|
23
|
+
export type ProcRunner = (command: string, args: string[], input: string, signal?: AbortSignal) => Promise<ProcResult>;
|
|
24
|
+
export declare const spawnRunner: ProcRunner;
|
|
25
|
+
/** CryptProtectData and CryptUnprotectData, current-user scope, with the entropy given. */
|
|
26
|
+
export interface Dpapi {
|
|
27
|
+
protect(data: Buffer, entropy: Buffer, signal?: AbortSignal): Promise<Buffer>;
|
|
28
|
+
unprotect(blob: Buffer, entropy: Buffer, signal?: AbortSignal): Promise<Buffer>;
|
|
29
|
+
}
|
|
30
|
+
export declare function powershellDpapi(run?: ProcRunner): Dpapi;
|
|
31
|
+
/** The macOS keychain's generic passwords under the vault's service. */
|
|
32
|
+
export interface Keychain {
|
|
33
|
+
/** The stored UTF-8 JSON, or null when there is no entry for this account. */
|
|
34
|
+
read(account: string, signal?: AbortSignal): Promise<string | null>;
|
|
35
|
+
write(account: string, json: string, signal?: AbortSignal): Promise<void>;
|
|
36
|
+
}
|
|
37
|
+
/** A chunked vault that can't be put back together: a chunk is missing or the checksum differs. Never a partial vault. */
|
|
38
|
+
export declare class VaultIncompleteError extends Error {
|
|
39
|
+
}
|
|
40
|
+
/** Through the `security` CLI. The data goes in on stdin (`security -i`), so it is never in the process list. */
|
|
41
|
+
export declare function securityKeychain(run?: ProcRunner): Keychain;
|
|
42
|
+
export interface VaultDeps {
|
|
43
|
+
platform: NodeJS.Platform;
|
|
44
|
+
dpapi: Dpapi;
|
|
45
|
+
keychain: Keychain;
|
|
46
|
+
}
|
|
47
|
+
export declare function realVaultDeps(): VaultDeps;
|
|
48
|
+
/** The protection this system has, if any. */
|
|
49
|
+
export declare function protectionOf(platform: NodeJS.Platform): Protection | null;
|
|
50
|
+
export declare function realPathOf(path: string): string;
|
|
51
|
+
/** A file's top level, read for how it is kept (protected or plain). `null` when it isn't an object. */
|
|
52
|
+
export declare function peekFile(text: string): {
|
|
53
|
+
protected?: string;
|
|
54
|
+
data?: string;
|
|
55
|
+
} | null;
|
|
56
|
+
export declare class VaultOpenError extends Error {
|
|
57
|
+
}
|
|
58
|
+
/** The decrypted JSON text of a protected file. Throws `VaultOpenError` with the reason (never a value). */
|
|
59
|
+
export declare function openProtected(path: string, file: {
|
|
60
|
+
protected?: string;
|
|
61
|
+
data?: string;
|
|
62
|
+
}, deps: VaultDeps, signal?: AbortSignal): Promise<string>;
|
|
63
|
+
/**
|
|
64
|
+
* `tapline vault protect FILE` (§20.2): reads a plain vault file and writes it in this system's protected form. A version 1
|
|
65
|
+
* file becomes version 2. Other top-level keys (Tapwright's `otp`) travel inside the protected data untouched.
|
|
66
|
+
*/
|
|
67
|
+
export declare function protectVaultFile(path: string, deps?: VaultDeps): Promise<{
|
|
68
|
+
ok: true;
|
|
69
|
+
how: Protection;
|
|
70
|
+
} | {
|
|
71
|
+
ok: false;
|
|
72
|
+
message: string;
|
|
73
|
+
}>;
|
|
74
|
+
/** The words `tapline vault protect` answers. */
|
|
75
|
+
export declare function protectedLine(path: string, how: Protection): string;
|
package/dist/vault.js
ADDED
|
@@ -0,0 +1,395 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { createHash } from "node:crypto";
|
|
3
|
+
import { existsSync, readFileSync, realpathSync, renameSync, writeFileSync } from "node:fs";
|
|
4
|
+
import { dirname, join, resolve } from "node:path";
|
|
5
|
+
import { vaultFileSchema } from "./accounts.js";
|
|
6
|
+
/**
|
|
7
|
+
* AGENT-TOOLS 1.14 §20.2: where the vault is kept. One format for both tools:
|
|
8
|
+
* - Windows: `{"version": 2, "protected": "dpapi-user", "data": <base64 of CryptProtectData(UTF-8 JSON)>}`, current-user scope,
|
|
9
|
+
* with the fixed entropy below. Tapline calls DPAPI through PowerShell, Tapwright through ctypes; the blob is the same.
|
|
10
|
+
* - macOS: a keychain generic password (service below, account = the file's real path), the same UTF-8 JSON as its data;
|
|
11
|
+
* the file holds `{"version": 2, "protected": "keychain"}`.
|
|
12
|
+
* - Plain: `{"version": 2, "vault": {...}}`, for test data only.
|
|
13
|
+
* Nothing here ever quotes a value: a failure says why in words.
|
|
14
|
+
*/
|
|
15
|
+
/** The optional entropy of the DPAPI blob (§20.2). */
|
|
16
|
+
export const DPAPI_ENTROPY = Buffer.from("tapline-tapwright-vault-v2", "utf8");
|
|
17
|
+
/** The keychain service (§20.2). */
|
|
18
|
+
export const KEYCHAIN_SERVICE = "tapline-tapwright-vault";
|
|
19
|
+
/** What the person is told at every start while the vault is a plain file (§20.2). */
|
|
20
|
+
export function plainWarning(path) {
|
|
21
|
+
return `Note: the vault file ${path} is not encrypted; keep test data in it only.`;
|
|
22
|
+
}
|
|
23
|
+
export const spawnRunner = (command, args, input, signal) => new Promise((done) => {
|
|
24
|
+
let out = "";
|
|
25
|
+
let err = "";
|
|
26
|
+
let child;
|
|
27
|
+
if (signal?.aborted === true) {
|
|
28
|
+
done({ code: null, stdout: "", stderr: "aborted" });
|
|
29
|
+
return;
|
|
30
|
+
}
|
|
31
|
+
try {
|
|
32
|
+
child = spawn(command, args, { windowsHide: true, stdio: ["pipe", "pipe", "pipe"] });
|
|
33
|
+
}
|
|
34
|
+
catch {
|
|
35
|
+
done({ code: null, stdout: "", stderr: "couldn't start" });
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
// An abort (the caller's time ran out) kills the program: a stalled keychain or PowerShell call never outlives its wait.
|
|
39
|
+
const onAbort = () => {
|
|
40
|
+
try {
|
|
41
|
+
child.kill("SIGKILL");
|
|
42
|
+
}
|
|
43
|
+
catch {
|
|
44
|
+
// already gone
|
|
45
|
+
}
|
|
46
|
+
done({ code: null, stdout: "", stderr: "aborted" });
|
|
47
|
+
};
|
|
48
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
49
|
+
child.stdout?.setEncoding("utf8").on("data", (chunk) => (out += chunk));
|
|
50
|
+
child.stderr?.setEncoding("utf8").on("data", (chunk) => (err += chunk));
|
|
51
|
+
child.on("error", () => done({ code: null, stdout: out, stderr: "couldn't start" }));
|
|
52
|
+
child.on("close", (code) => {
|
|
53
|
+
signal?.removeEventListener("abort", onAbort);
|
|
54
|
+
done({ code, stdout: out, stderr: err });
|
|
55
|
+
});
|
|
56
|
+
child.stdin?.on("error", () => undefined);
|
|
57
|
+
child.stdin?.end(input);
|
|
58
|
+
});
|
|
59
|
+
/**
|
|
60
|
+
* The script only ever sees its input on stdin: the mode, the data and the entropy, one base64 line each. Nothing secret is
|
|
61
|
+
* in the command line.
|
|
62
|
+
*/
|
|
63
|
+
const DPAPI_SCRIPT = [
|
|
64
|
+
"$ErrorActionPreference = 'Stop'",
|
|
65
|
+
"Add-Type -AssemblyName System.Security",
|
|
66
|
+
"$l = [Console]::In.ReadToEnd() -split '\\r?\\n'",
|
|
67
|
+
"$d = [Convert]::FromBase64String($l[1])",
|
|
68
|
+
"$e = [Convert]::FromBase64String($l[2])",
|
|
69
|
+
"$s = [System.Security.Cryptography.DataProtectionScope]::CurrentUser",
|
|
70
|
+
"if ($l[0] -eq 'protect') { $o = [System.Security.Cryptography.ProtectedData]::Protect($d, $e, $s) } else { $o = [System.Security.Cryptography.ProtectedData]::Unprotect($d, $e, $s) }",
|
|
71
|
+
"[Convert]::ToBase64String($o)",
|
|
72
|
+
].join("; ");
|
|
73
|
+
export function powershellDpapi(run = spawnRunner) {
|
|
74
|
+
const call = async (mode, data, entropy, signal) => {
|
|
75
|
+
const out = await run("powershell.exe", ["-NoProfile", "-NonInteractive", "-Command", DPAPI_SCRIPT], `${mode}\n${data.toString("base64")}\n${entropy.toString("base64")}\n`, signal);
|
|
76
|
+
if (out.code !== 0 || out.stdout.trim() === "") {
|
|
77
|
+
throw new Error(mode === "protect" ? "Windows couldn't protect the data" : "Windows couldn't decrypt it (it was protected for another user or another computer)");
|
|
78
|
+
}
|
|
79
|
+
return Buffer.from(out.stdout.trim(), "base64");
|
|
80
|
+
};
|
|
81
|
+
return { protect: (data, entropy, signal) => call("protect", data, entropy, signal), unprotect: (blob, entropy, signal) => call("unprotect", blob, entropy, signal) };
|
|
82
|
+
}
|
|
83
|
+
function securityQuote(text) {
|
|
84
|
+
return `"${text.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`;
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* `security -i` reads at most 4095 bytes per input line, and a line is the whole command with its hex data: about 1.9 KB of vault
|
|
88
|
+
* JSON at most. A vault that fits is ONE item holding the plain vault JSON (§20.2, single form). A larger one is the chunked form
|
|
89
|
+
* (AGENT-TOOLS 1.14.1): chunk items at account `<realpath>#1`, `#2`, … hold consecutive pieces of the JSON, and the main item
|
|
90
|
+
* (account = the realpath) holds the manifest `{"chunks": n, "sha256": "<hex of the whole JSON>"}`. A reader tells the forms apart
|
|
91
|
+
* by the `chunks` key. The chunk size is the writer's own choice; the reader joins whatever is there.
|
|
92
|
+
*/
|
|
93
|
+
const LINE_BUDGET = 3900;
|
|
94
|
+
const MIN_CHUNK = 64;
|
|
95
|
+
/**
|
|
96
|
+
* Chunks that could be read as hex or as text. Each chunk's reading is decided on its own where it can be (one whose hex isn't valid
|
|
97
|
+
* UTF-8 is raw), and only the truly ambiguous ones are searched, with the manifest's sha256 as the oracle: up to 2^12 combinations.
|
|
98
|
+
* More than this many fails closed with the doctor message. The writer never stores an ambiguous chunk (see `write`).
|
|
99
|
+
*/
|
|
100
|
+
const MAX_AMBIGUOUS = 12;
|
|
101
|
+
/** A chunked vault that can't be put back together: a chunk is missing or the checksum differs. Never a partial vault. */
|
|
102
|
+
export class VaultIncompleteError extends Error {
|
|
103
|
+
}
|
|
104
|
+
/** The chunk count of a manifest, or null when the text is a plain vault (no `chunks` key) or not JSON. */
|
|
105
|
+
function manifestOf(text) {
|
|
106
|
+
if (text === null || !text.startsWith("{"))
|
|
107
|
+
return null;
|
|
108
|
+
try {
|
|
109
|
+
const top = JSON.parse(text);
|
|
110
|
+
if (typeof top !== "object" || top === null || Array.isArray(top) || !("chunks" in top))
|
|
111
|
+
return null;
|
|
112
|
+
const { chunks, sha256 } = top;
|
|
113
|
+
return { chunks: typeof chunks === "number" && Number.isInteger(chunks) && chunks >= 1 && chunks <= 100_000 ? chunks : 0, sha256: typeof sha256 === "string" ? sha256 : null };
|
|
114
|
+
}
|
|
115
|
+
catch {
|
|
116
|
+
return null;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
const chunkAccount = (account, index) => `${account}#${index}`;
|
|
120
|
+
/** Through the `security` CLI. The data goes in on stdin (`security -i`), so it is never in the process list. */
|
|
121
|
+
export function securityKeychain(run = spawnRunner) {
|
|
122
|
+
/**
|
|
123
|
+
* The stored text of one entry as `security -w` printed it, and the other reading when it could be hex. `security -w` prints
|
|
124
|
+
* data that isn't plain text as hex, but plain text made only of hex digits (a chunk such as "41" repeated) looks the same: both
|
|
125
|
+
* readings are kept, and the caller (the manifest's checksum, or the writer's own piece) says which one is right.
|
|
126
|
+
*/
|
|
127
|
+
const readBoth = async (account, signal) => {
|
|
128
|
+
const out = await run("security", ["find-generic-password", "-s", KEYCHAIN_SERVICE, "-a", account, "-w"], "", signal);
|
|
129
|
+
if (out.code !== 0)
|
|
130
|
+
return null;
|
|
131
|
+
const text = out.stdout.replace(/\r?\n$/, "");
|
|
132
|
+
if (text === "")
|
|
133
|
+
return null;
|
|
134
|
+
if (text.startsWith("{") || !/^[0-9a-fA-F]+$/.test(text) || text.length % 2 !== 0)
|
|
135
|
+
return { raw: text, decoded: null };
|
|
136
|
+
try {
|
|
137
|
+
const decoded = new TextDecoder("utf-8", { fatal: true }).decode(Buffer.from(text, "hex"));
|
|
138
|
+
return { raw: text, decoded: decoded === text ? null : decoded };
|
|
139
|
+
}
|
|
140
|
+
catch {
|
|
141
|
+
return { raw: text, decoded: null };
|
|
142
|
+
}
|
|
143
|
+
};
|
|
144
|
+
/** The stored text of one entry, or null when there is none (real hex decodes to UTF-8; for a lone item the decoded reading is the one). */
|
|
145
|
+
const readText = async (account, signal) => {
|
|
146
|
+
const both = await readBoth(account, signal);
|
|
147
|
+
return both === null ? null : (both.decoded ?? both.raw);
|
|
148
|
+
};
|
|
149
|
+
const read = async (account, signal) => {
|
|
150
|
+
const text = await readText(account, signal);
|
|
151
|
+
const manifest = manifestOf(text);
|
|
152
|
+
if (manifest === null)
|
|
153
|
+
return text;
|
|
154
|
+
if (manifest.chunks === 0 || manifest.sha256 === null)
|
|
155
|
+
throw new VaultIncompleteError("the vault in the keychain is incomplete (its chunk list is unreadable)");
|
|
156
|
+
const readings = [];
|
|
157
|
+
for (let i = 1; i <= manifest.chunks; i++) {
|
|
158
|
+
signal?.throwIfAborted();
|
|
159
|
+
const chunk = await readBoth(chunkAccount(account, i), signal);
|
|
160
|
+
if (chunk === null)
|
|
161
|
+
throw new VaultIncompleteError(`the vault in the keychain is incomplete (chunk ${i} of ${manifest.chunks} is missing)`);
|
|
162
|
+
readings.push({ raw: Buffer.from(chunk.raw, "utf8"), decoded: chunk.decoded === null ? null : Buffer.from(chunk.decoded, "utf8") });
|
|
163
|
+
}
|
|
164
|
+
const wanted = manifest.sha256.toLowerCase();
|
|
165
|
+
const matches = (pieces) => {
|
|
166
|
+
const whole = Buffer.concat(pieces);
|
|
167
|
+
return createHash("sha256").update(whole).digest("hex") === wanted ? whole : null;
|
|
168
|
+
};
|
|
169
|
+
const ambiguous = readings.flatMap((r, i) => (r.decoded === null ? [] : [i]));
|
|
170
|
+
// The reading of each ambiguous chunk is whatever makes the checksum come out; the search is bounded.
|
|
171
|
+
let found = null;
|
|
172
|
+
// The two plain readings first (a long text of many non-ASCII chunks is all-decoded however many chunks there are), then the search.
|
|
173
|
+
found = matches(readings.map((r) => r.raw)) ?? matches(readings.map((r) => r.decoded ?? r.raw));
|
|
174
|
+
if (found === null && ambiguous.length <= MAX_AMBIGUOUS) {
|
|
175
|
+
for (let mask = 0; mask < 2 ** ambiguous.length && found === null; mask++) {
|
|
176
|
+
found = matches(readings.map((r, i) => {
|
|
177
|
+
const at = ambiguous.indexOf(i);
|
|
178
|
+
return at >= 0 && (mask >> at) & 1 ? r.decoded : r.raw;
|
|
179
|
+
}));
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
if (found === null)
|
|
183
|
+
throw new VaultIncompleteError("the vault in the keychain is incomplete (its chunks don't match their checksum)");
|
|
184
|
+
return found.toString("utf8");
|
|
185
|
+
};
|
|
186
|
+
const addLine = (account, data) => `add-generic-password -U -s ${securityQuote(KEYCHAIN_SERVICE)} -a ${securityQuote(account)} -X ${data.toString("hex")}`;
|
|
187
|
+
/** Stores one entry. The interactive mode doesn't always set the exit code, so the caller reads back what mattered. */
|
|
188
|
+
const store = async (account, data) => {
|
|
189
|
+
await run("security", ["-i"], `${addLine(account, data)}\n`);
|
|
190
|
+
};
|
|
191
|
+
/** Deletes the chunk items `from` to `to` (stale ones of an earlier, longer vault). */
|
|
192
|
+
const dropChunks = async (account, from, to) => {
|
|
193
|
+
for (let i = from; i <= to; i++)
|
|
194
|
+
await run("security", ["delete-generic-password", "-s", KEYCHAIN_SERVICE, "-a", chunkAccount(account, i)], "").catch(() => undefined);
|
|
195
|
+
};
|
|
196
|
+
return {
|
|
197
|
+
read,
|
|
198
|
+
async write(account, json) {
|
|
199
|
+
const previous = manifestOf(await readText(account))?.chunks ?? 0;
|
|
200
|
+
const data = Buffer.from(json, "utf8");
|
|
201
|
+
if (Buffer.byteLength(addLine(account, data)) <= LINE_BUDGET) {
|
|
202
|
+
await store(account, data);
|
|
203
|
+
if ((await read(account)) !== json)
|
|
204
|
+
throw new Error("the keychain didn't keep the entry");
|
|
205
|
+
await dropChunks(account, 1, previous);
|
|
206
|
+
return;
|
|
207
|
+
}
|
|
208
|
+
const size = Math.floor((LINE_BUDGET - Buffer.byteLength(addLine(chunkAccount(account, 999_999), Buffer.alloc(0)))) / 2) - 3;
|
|
209
|
+
if (size < MIN_CHUNK)
|
|
210
|
+
throw new Error("the vault file's path is too long to store in the macOS keychain");
|
|
211
|
+
// pieces are cut at character boundaries, so every chunk is valid UTF-8 text on its own
|
|
212
|
+
const pieces = [];
|
|
213
|
+
for (let at = 0; at < data.length;) {
|
|
214
|
+
let end = Math.min(at + size, data.length);
|
|
215
|
+
while (end < data.length && end > at && ((data[end] ?? 0) & 0xc0) === 0x80)
|
|
216
|
+
end--;
|
|
217
|
+
// A piece of only hex digits, in an even number, reads two ways (`security -w` prints data that isn't text as hex): cut it one
|
|
218
|
+
// byte short so no stored chunk is ambiguous. The reader still takes the frozen form (§20.2), so older vaults read as before.
|
|
219
|
+
if (end < data.length && end - at > 1 && (end - at) % 2 === 0 && /^[0-9a-fA-F]+$/.test(data.subarray(at, end).toString("latin1")))
|
|
220
|
+
end--;
|
|
221
|
+
pieces.push(data.subarray(at, end));
|
|
222
|
+
at = end;
|
|
223
|
+
}
|
|
224
|
+
// chunks #1…#n first, then the manifest, then the stale chunks of an earlier, longer vault
|
|
225
|
+
for (const [i, piece] of pieces.entries()) {
|
|
226
|
+
await store(chunkAccount(account, i + 1), piece);
|
|
227
|
+
const back = await readBoth(chunkAccount(account, i + 1));
|
|
228
|
+
if (back === null || (back.raw !== piece.toString("utf8") && back.decoded !== piece.toString("utf8")))
|
|
229
|
+
throw new Error("the keychain didn't keep the entry");
|
|
230
|
+
}
|
|
231
|
+
await store(account, Buffer.from(JSON.stringify({ chunks: pieces.length, sha256: createHash("sha256").update(data).digest("hex") }), "utf8"));
|
|
232
|
+
if ((await read(account)) !== json)
|
|
233
|
+
throw new Error("the keychain didn't keep the entry");
|
|
234
|
+
await dropChunks(account, pieces.length + 1, previous);
|
|
235
|
+
},
|
|
236
|
+
};
|
|
237
|
+
}
|
|
238
|
+
export function realVaultDeps() {
|
|
239
|
+
return { platform: process.platform, dpapi: powershellDpapi(), keychain: securityKeychain() };
|
|
240
|
+
}
|
|
241
|
+
/** The protection this system has, if any. */
|
|
242
|
+
export function protectionOf(platform) {
|
|
243
|
+
return platform === "win32" ? "dpapi-user" : platform === "darwin" ? "keychain" : null;
|
|
244
|
+
}
|
|
245
|
+
export function realPathOf(path) {
|
|
246
|
+
try {
|
|
247
|
+
return realpathSync(resolve(path));
|
|
248
|
+
}
|
|
249
|
+
catch {
|
|
250
|
+
return resolve(path);
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
/** A file's top level, read for how it is kept (protected or plain). `null` when it isn't an object. */
|
|
254
|
+
export function peekFile(text) {
|
|
255
|
+
try {
|
|
256
|
+
const parsed = JSON.parse(text);
|
|
257
|
+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed))
|
|
258
|
+
return null;
|
|
259
|
+
const top = parsed;
|
|
260
|
+
return { ...(typeof top.protected === "string" ? { protected: top.protected } : {}), ...(typeof top.data === "string" ? { data: top.data } : {}) };
|
|
261
|
+
}
|
|
262
|
+
catch {
|
|
263
|
+
return null;
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
export class VaultOpenError extends Error {
|
|
267
|
+
}
|
|
268
|
+
/** The decrypted JSON text of a protected file. Throws `VaultOpenError` with the reason (never a value). */
|
|
269
|
+
export async function openProtected(path, file, deps, signal) {
|
|
270
|
+
if (file.protected === "dpapi-user") {
|
|
271
|
+
if (deps.platform !== "win32")
|
|
272
|
+
throw new VaultOpenError("the file is protected with Windows DPAPI and can only be opened on Windows");
|
|
273
|
+
if (file.data === undefined || file.data === "")
|
|
274
|
+
throw new VaultOpenError("the protected file holds no data");
|
|
275
|
+
try {
|
|
276
|
+
return (await deps.dpapi.unprotect(Buffer.from(file.data, "base64"), DPAPI_ENTROPY, signal)).toString("utf8");
|
|
277
|
+
}
|
|
278
|
+
catch (error) {
|
|
279
|
+
throw new VaultOpenError(error instanceof Error ? error.message : "Windows couldn't decrypt it");
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
if (file.protected === "keychain") {
|
|
283
|
+
if (deps.platform !== "darwin")
|
|
284
|
+
throw new VaultOpenError("the file is protected with the macOS keychain and can only be opened on a Mac");
|
|
285
|
+
let found;
|
|
286
|
+
try {
|
|
287
|
+
found = await deps.keychain.read(realPathOf(path), signal);
|
|
288
|
+
}
|
|
289
|
+
catch (error) {
|
|
290
|
+
// a chunked vault with a missing chunk or a wrong checksum: fail closed, and say why (§20.2)
|
|
291
|
+
if (error instanceof VaultIncompleteError)
|
|
292
|
+
throw new VaultOpenError(error.message);
|
|
293
|
+
found = null;
|
|
294
|
+
}
|
|
295
|
+
if (found === null)
|
|
296
|
+
throw new VaultOpenError("the keychain has no entry for this file's path (the file was moved, or it was protected by another user)");
|
|
297
|
+
return found;
|
|
298
|
+
}
|
|
299
|
+
throw new VaultOpenError(`the file says it is protected as "${file.protected ?? ""}", which this Tapline doesn't know`);
|
|
300
|
+
}
|
|
301
|
+
/**
|
|
302
|
+
* `tapline vault protect FILE` (§20.2): reads a plain vault file and writes it in this system's protected form. A version 1
|
|
303
|
+
* file becomes version 2. Other top-level keys (Tapwright's `otp`) travel inside the protected data untouched.
|
|
304
|
+
*/
|
|
305
|
+
export async function protectVaultFile(path, deps = realVaultDeps()) {
|
|
306
|
+
const file = resolve(path);
|
|
307
|
+
if (!existsSync(file))
|
|
308
|
+
return { ok: false, message: `the vault file ${file} doesn't exist.` };
|
|
309
|
+
const how = protectionOf(deps.platform);
|
|
310
|
+
if (how === null)
|
|
311
|
+
return { ok: false, message: "this system has no protection for the vault (Windows DPAPI or the macOS keychain); the file stays plain, for test data only." };
|
|
312
|
+
const real = realPathOf(file);
|
|
313
|
+
let text;
|
|
314
|
+
try {
|
|
315
|
+
text = readFileSync(real, "utf8");
|
|
316
|
+
}
|
|
317
|
+
catch {
|
|
318
|
+
return { ok: false, message: `couldn't read ${file}.` };
|
|
319
|
+
}
|
|
320
|
+
const peek = peekFile(text);
|
|
321
|
+
if (peek?.protected !== undefined)
|
|
322
|
+
return { ok: false, message: `the vault file ${file} is already protected.` };
|
|
323
|
+
let parsed;
|
|
324
|
+
try {
|
|
325
|
+
parsed = JSON.parse(text);
|
|
326
|
+
}
|
|
327
|
+
catch {
|
|
328
|
+
return { ok: false, message: `the vault file ${file} isn't valid JSON.` };
|
|
329
|
+
}
|
|
330
|
+
const top = typeof parsed === "object" && parsed !== null && !Array.isArray(parsed) ? parsed : null;
|
|
331
|
+
if (top === null)
|
|
332
|
+
return { ok: false, message: `the vault file ${file} isn't a vault.` };
|
|
333
|
+
let payload;
|
|
334
|
+
if (top["version"] === 2 && typeof top["vault"] === "object" && top["vault"] !== null) {
|
|
335
|
+
payload = top;
|
|
336
|
+
}
|
|
337
|
+
else if (top["version"] === 1 && typeof top["accounts"] === "object" && top["accounts"] !== null) {
|
|
338
|
+
const { accounts, version: _version, ...rest } = top;
|
|
339
|
+
const vault = {};
|
|
340
|
+
for (const [name, entry] of Object.entries(accounts)) {
|
|
341
|
+
const fields = typeof entry === "object" && entry !== null ? entry : {};
|
|
342
|
+
const { passcode, ...others } = fields;
|
|
343
|
+
const onlyPasscode = passcode !== undefined && fields["username"] === undefined && fields["password"] === undefined;
|
|
344
|
+
if (onlyPasscode) {
|
|
345
|
+
vault[name] = { kind: "phone", ...fields };
|
|
346
|
+
continue;
|
|
347
|
+
}
|
|
348
|
+
vault[name] = { kind: "account", ...others };
|
|
349
|
+
// A version 2 account has no passcode field: it moves into a phone entry of its own, so it stays stored and scrubbed.
|
|
350
|
+
if (passcode !== undefined) {
|
|
351
|
+
let phone = `${name.slice(0, 52)}_phone`;
|
|
352
|
+
for (let n = 2; Object.prototype.hasOwnProperty.call(accounts, phone) || Object.prototype.hasOwnProperty.call(vault, phone); n++)
|
|
353
|
+
phone = `${name.slice(0, 50)}_phone${n}`;
|
|
354
|
+
vault[phone] = { kind: "phone", passcode };
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
payload = { version: 2, vault, ...rest };
|
|
358
|
+
}
|
|
359
|
+
else {
|
|
360
|
+
return { ok: false, message: `the vault file ${file} isn't a version 1 or 2 vault.` };
|
|
361
|
+
}
|
|
362
|
+
// What will be written must load: a file that doesn't would replace the plain one and leave the vault unusable (there is no unprotect).
|
|
363
|
+
const valid = vaultFileSchema.safeParse(payload);
|
|
364
|
+
if (!valid.success) {
|
|
365
|
+
const why = valid.error.issues.map((issue) => `${issue.path.join(".") || "file"}: ${issue.message}`).join("; ");
|
|
366
|
+
return { ok: false, message: `the vault file ${file} can't be protected as it is (${why}); fix it first. The file stays plain.` };
|
|
367
|
+
}
|
|
368
|
+
const json = JSON.stringify(payload);
|
|
369
|
+
try {
|
|
370
|
+
let out;
|
|
371
|
+
if (how === "dpapi-user") {
|
|
372
|
+
const blob = await deps.dpapi.protect(Buffer.from(json, "utf8"), DPAPI_ENTROPY);
|
|
373
|
+
out = JSON.stringify({ version: 2, protected: "dpapi-user", data: blob.toString("base64") });
|
|
374
|
+
}
|
|
375
|
+
else {
|
|
376
|
+
await deps.keychain.write(real, json);
|
|
377
|
+
out = JSON.stringify({ version: 2, protected: "keychain" });
|
|
378
|
+
}
|
|
379
|
+
// Written beside the file and moved in, so a failure never leaves half a file.
|
|
380
|
+
const temp = join(dirname(real), `.${Date.now().toString(36)}.vault.tmp`);
|
|
381
|
+
writeFileSync(temp, `${out}\n`, { mode: 0o600 });
|
|
382
|
+
renameSync(temp, real);
|
|
383
|
+
}
|
|
384
|
+
catch (error) {
|
|
385
|
+
return { ok: false, message: `couldn't protect the file: ${error instanceof Error ? error.message : "an error"}.` };
|
|
386
|
+
}
|
|
387
|
+
return { ok: true, how };
|
|
388
|
+
}
|
|
389
|
+
/** The words `tapline vault protect` answers. */
|
|
390
|
+
export function protectedLine(path, how) {
|
|
391
|
+
return how === "dpapi-user"
|
|
392
|
+
? `Protected ${resolve(path)} with Windows DPAPI (current user). Tapwright reads it too.`
|
|
393
|
+
: `Protected ${resolve(path)} in the macOS keychain (service ${KEYCHAIN_SERVICE}; the entry belongs to this path, so a moved file needs protecting again).`;
|
|
394
|
+
}
|
|
395
|
+
//# sourceMappingURL=vault.js.map
|