@atrib/attest 0.0.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/LICENSE +216 -0
- package/README.md +187 -0
- package/dist/annotate.d.ts +56 -0
- package/dist/annotate.js +135 -0
- package/dist/attest.d.ts +69 -0
- package/dist/attest.js +214 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +729 -0
- package/dist/index.d.ts +312 -0
- package/dist/index.js +1028 -0
- package/dist/keys.d.ts +21 -0
- package/dist/keys.js +134 -0
- package/dist/local-substrate-host.d.ts +93 -0
- package/dist/local-substrate-host.js +467 -0
- package/dist/main.d.ts +2 -0
- package/dist/main.js +18 -0
- package/dist/reference-resolution.d.ts +12 -0
- package/dist/reference-resolution.js +31 -0
- package/dist/revise.d.ts +43 -0
- package/dist/revise.js +133 -0
- package/dist/session-checkpoint.d.ts +103 -0
- package/dist/session-checkpoint.js +285 -0
- package/dist/sign.d.ts +69 -0
- package/dist/sign.js +74 -0
- package/dist/storage.d.ts +27 -0
- package/dist/storage.js +59 -0
- package/package.json +72 -0
package/dist/keys.d.ts
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
export interface ResolvedKey {
|
|
2
|
+
privateKey: Uint8Array;
|
|
3
|
+
/** Source the key came from. Surfaced in startup logs so operators can confirm key provenance. */
|
|
4
|
+
source: 'env' | 'file' | 'keychain' | 'op';
|
|
5
|
+
/** Which Keychain service yielded the key, when source === 'keychain'. */
|
|
6
|
+
keychainService?: string;
|
|
7
|
+
/** The `op://` reference that yielded the key, when source === 'op'. */
|
|
8
|
+
opReference?: string;
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* Resolve the agent's signing key. Returns null when no key is available;
|
|
12
|
+
* callers run in pass-through mode (per §5.8 degradation) and the emit
|
|
13
|
+
* tool returns a warning rather than crashing.
|
|
14
|
+
*
|
|
15
|
+
* The agent name (defaults to 'claude-code', override with ATRIB_AGENT)
|
|
16
|
+
* picks the agent-scoped Keychain service first, falling back to the
|
|
17
|
+
* generic 'atrib-creator' service. This matches the wrapper exactly: a
|
|
18
|
+
* Keychain entry created via `atrib keygen --keychain --service
|
|
19
|
+
* atrib-creator-claude-code` resolves identically here and in the wrapper.
|
|
20
|
+
*/
|
|
21
|
+
export declare function resolveKey(): Promise<ResolvedKey | null>;
|
package/dist/keys.js
ADDED
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
// Key resolution chain for atrib-emit. MUST mirror the agent's wrapper
|
|
2
|
+
// service resolution order exactly for the first three sources, emit
|
|
3
|
+
// signs records under the same identity as the wrapper, so a divergence
|
|
4
|
+
// here means the two producers would sign as different identities in the
|
|
5
|
+
// same session, breaking the chain assumption and creating mystery keys
|
|
6
|
+
// in the log.
|
|
7
|
+
//
|
|
8
|
+
// Resolution order (first hit wins):
|
|
9
|
+
// 1. ATRIB_PRIVATE_KEY env var (legacy / dev path)
|
|
10
|
+
// 2. ATRIB_KEY_FILE env var → 0600 file
|
|
11
|
+
// 3. macOS Keychain, account = current user, services tried in order:
|
|
12
|
+
// - atrib-creator-<ATRIB_AGENT> (agent-scoped; matches wrapper)
|
|
13
|
+
// - atrib-creator (generic fallback)
|
|
14
|
+
// 4. 1Password CLI (`op read`), recovery path when Keychain is wiped.
|
|
15
|
+
// Off by default; enable by setting ATRIB_OP_REFERENCE to a valid
|
|
16
|
+
// `op://<vault>/<item>/<field>` reference. Requires the operator
|
|
17
|
+
// to be signed in (`op signin`) and willing to approve the read.
|
|
18
|
+
//
|
|
19
|
+
// The `op` fallback is deliberately last so that, in a healthy machine,
|
|
20
|
+
// the seed never leaves Keychain. It only fires when Keychain is empty,
|
|
21
|
+
// e.g., after a Keychain reset, fresh machine, or a corruption event.
|
|
22
|
+
// In that case the seed flows through `op read` stdout into our process
|
|
23
|
+
// memory; never touches argv (the reference contains no secret).
|
|
24
|
+
//
|
|
25
|
+
// Wrapper source of truth lives in the operator's internal repo; this
|
|
26
|
+
// resolution chain must be kept in lockstep with that wrapper.
|
|
27
|
+
//
|
|
28
|
+
// Sources 3 and 4 shell out to external binaries (`security`, `op`) that
|
|
29
|
+
// can block indefinitely in headless contexts: a locked login Keychain
|
|
30
|
+
// has no GUI to unlock against, and `op read` waits on a biometric
|
|
31
|
+
// approval no one will give in cron. Both spawnSync calls are bounded by
|
|
32
|
+
// a timeout so resolveKey() always returns within a few seconds and the
|
|
33
|
+
// caller fails fast into the §5.8 pass-through path instead of stalling
|
|
34
|
+
// the atrib-emit MCP init handshake (the observed 15s connect timeout in
|
|
35
|
+
// session-end / cron contexts). Tune via ATRIB_KEYCHAIN_TIMEOUT_MS and
|
|
36
|
+
// ATRIB_OP_TIMEOUT_MS.
|
|
37
|
+
import { readFile } from 'node:fs/promises';
|
|
38
|
+
import { spawnSync } from 'node:child_process';
|
|
39
|
+
import { userInfo } from 'node:os';
|
|
40
|
+
import { base64urlDecode } from '@atrib/mcp';
|
|
41
|
+
/**
|
|
42
|
+
* Upper bound on the `security find-generic-password` call. A healthy,
|
|
43
|
+
* unlocked Keychain answers in well under 100ms; the timeout exists to
|
|
44
|
+
* cap a *hung* Keychain (locked login keychain, headless context) so
|
|
45
|
+
* resolveKey falls through rather than stalling. Default 3s.
|
|
46
|
+
*/
|
|
47
|
+
const KEYCHAIN_TIMEOUT_MS = Number(process.env['ATRIB_KEYCHAIN_TIMEOUT_MS'] ?? '3000');
|
|
48
|
+
/**
|
|
49
|
+
* Upper bound on the `op read` recovery call. Larger than the Keychain
|
|
50
|
+
* bound because `op read` may legitimately wait on an interactive Touch
|
|
51
|
+
* ID approval; still bounded so a headless invocation cannot hang. Default 10s.
|
|
52
|
+
*/
|
|
53
|
+
const OP_TIMEOUT_MS = Number(process.env['ATRIB_OP_TIMEOUT_MS'] ?? '10000');
|
|
54
|
+
/**
|
|
55
|
+
* Resolve the agent's signing key. Returns null when no key is available;
|
|
56
|
+
* callers run in pass-through mode (per §5.8 degradation) and the emit
|
|
57
|
+
* tool returns a warning rather than crashing.
|
|
58
|
+
*
|
|
59
|
+
* The agent name (defaults to 'claude-code', override with ATRIB_AGENT)
|
|
60
|
+
* picks the agent-scoped Keychain service first, falling back to the
|
|
61
|
+
* generic 'atrib-creator' service. This matches the wrapper exactly: a
|
|
62
|
+
* Keychain entry created via `atrib keygen --keychain --service
|
|
63
|
+
* atrib-creator-claude-code` resolves identically here and in the wrapper.
|
|
64
|
+
*/
|
|
65
|
+
export async function resolveKey() {
|
|
66
|
+
const envSeed = process.env['ATRIB_PRIVATE_KEY'];
|
|
67
|
+
if (envSeed) {
|
|
68
|
+
return { privateKey: decodeSeed(envSeed), source: 'env' };
|
|
69
|
+
}
|
|
70
|
+
const filePath = process.env['ATRIB_KEY_FILE'];
|
|
71
|
+
if (filePath) {
|
|
72
|
+
const contents = (await readFile(filePath, 'utf-8')).trim();
|
|
73
|
+
return { privateKey: decodeSeed(contents), source: 'file' };
|
|
74
|
+
}
|
|
75
|
+
if (process.platform === 'darwin') {
|
|
76
|
+
const account = process.env['ATRIB_KEYCHAIN_ACCOUNT'] ?? userInfo().username;
|
|
77
|
+
const agent = process.env['ATRIB_AGENT'] ?? 'claude-code';
|
|
78
|
+
const services = [`atrib-creator-${agent}`, 'atrib-creator'];
|
|
79
|
+
for (const service of services) {
|
|
80
|
+
const result = spawnSync('security', ['find-generic-password', '-a', account, '-s', service, '-w'], { encoding: 'utf-8', timeout: KEYCHAIN_TIMEOUT_MS });
|
|
81
|
+
if (result.error?.code === 'ETIMEDOUT') {
|
|
82
|
+
// Keychain subsystem is unresponsive (locked login keychain in a
|
|
83
|
+
// headless context). The second service would hang identically;
|
|
84
|
+
// stop retrying and fall through so the caller fails fast into
|
|
85
|
+
// §5.8 pass-through instead of paying the timeout twice.
|
|
86
|
+
break;
|
|
87
|
+
}
|
|
88
|
+
if (result.status === 0) {
|
|
89
|
+
const seed = result.stdout.trim();
|
|
90
|
+
if (seed.length > 0) {
|
|
91
|
+
return { privateKey: decodeSeed(seed), source: 'keychain', keychainService: service };
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
// 1Password recovery path (last resort). Activated only when
|
|
97
|
+
// ATRIB_OP_REFERENCE is set; the reference itself is non-secret.
|
|
98
|
+
// ATRIB_OP_ACCOUNT optionally pins which 1Password account, useful for
|
|
99
|
+
// operators with multiple accounts (e.g. personal + work).
|
|
100
|
+
const opReference = process.env['ATRIB_OP_REFERENCE'];
|
|
101
|
+
if (opReference) {
|
|
102
|
+
const args = ['read'];
|
|
103
|
+
const opAccount = process.env['ATRIB_OP_ACCOUNT'];
|
|
104
|
+
if (opAccount)
|
|
105
|
+
args.push('--account', opAccount);
|
|
106
|
+
args.push(opReference);
|
|
107
|
+
const result = spawnSync('op', args, { encoding: 'utf-8', timeout: OP_TIMEOUT_MS });
|
|
108
|
+
if (result.status === 0) {
|
|
109
|
+
// 1Password items often store seeds with a label prefix like
|
|
110
|
+
// "ATRIB_PRIVATE_KEY=<seed>" so the operator can tell which field
|
|
111
|
+
// is which in the UI. Strip an optional `ATRIB_PRIVATE_KEY=` prefix
|
|
112
|
+
// before decoding so both shapes work.
|
|
113
|
+
const raw = result.stdout.trim();
|
|
114
|
+
const seed = raw.startsWith('ATRIB_PRIVATE_KEY=')
|
|
115
|
+
? raw.slice('ATRIB_PRIVATE_KEY='.length).trim()
|
|
116
|
+
: raw;
|
|
117
|
+
if (seed.length > 0) {
|
|
118
|
+
return { privateKey: decodeSeed(seed), source: 'op', opReference };
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
return null;
|
|
123
|
+
}
|
|
124
|
+
/**
|
|
125
|
+
* Decode a base64url-encoded 32-byte Ed25519 seed. Throws if length wrong.
|
|
126
|
+
* Per spec §1.4.1: atrib uses 32-byte seeds, not the 64-byte NaCl format.
|
|
127
|
+
*/
|
|
128
|
+
function decodeSeed(b64url) {
|
|
129
|
+
const bytes = base64urlDecode(b64url.trim());
|
|
130
|
+
if (bytes.length !== 32) {
|
|
131
|
+
throw new Error(`atrib-emit: expected 32-byte Ed25519 seed, got ${bytes.length} bytes from key source`);
|
|
132
|
+
}
|
|
133
|
+
return bytes;
|
|
134
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { type InProcessLocalSubstrateCoordinator, type LocalSubstrateHarnessClass, type LocalSubstrateNodeServerHandle } from '@atrib/mcp';
|
|
3
|
+
import { type ResolvedKey } from './keys.js';
|
|
4
|
+
interface ParsedArgs {
|
|
5
|
+
host?: string;
|
|
6
|
+
port?: number;
|
|
7
|
+
maxBodyBytes?: number;
|
|
8
|
+
logEndpoint?: string;
|
|
9
|
+
logSubmission?: 'enabled' | 'disabled';
|
|
10
|
+
harnessClasses?: LocalSubstrateHarnessClass[];
|
|
11
|
+
shutdownTimeoutMs?: number;
|
|
12
|
+
showVersion: boolean;
|
|
13
|
+
showHelp: boolean;
|
|
14
|
+
showDescribe: boolean;
|
|
15
|
+
jsonOutput: boolean;
|
|
16
|
+
}
|
|
17
|
+
interface HostDescription {
|
|
18
|
+
name: 'atrib-local-substrate';
|
|
19
|
+
version: string;
|
|
20
|
+
description: string;
|
|
21
|
+
options: Array<{
|
|
22
|
+
flag: string;
|
|
23
|
+
takes_value: boolean;
|
|
24
|
+
description: string;
|
|
25
|
+
}>;
|
|
26
|
+
env_vars: Array<{
|
|
27
|
+
name: string;
|
|
28
|
+
description: string;
|
|
29
|
+
required: boolean;
|
|
30
|
+
}>;
|
|
31
|
+
}
|
|
32
|
+
interface StartHostOptions {
|
|
33
|
+
key: ResolvedKey;
|
|
34
|
+
host?: string;
|
|
35
|
+
port?: number;
|
|
36
|
+
maxBodyBytes?: number;
|
|
37
|
+
logEndpoint?: string;
|
|
38
|
+
logSubmission?: 'enabled' | 'disabled';
|
|
39
|
+
harnessClasses?: readonly LocalSubstrateHarnessClass[];
|
|
40
|
+
shutdownTimeoutMs?: number;
|
|
41
|
+
}
|
|
42
|
+
interface LocalSubstrateHostHandle {
|
|
43
|
+
coordinator: InProcessLocalSubstrateCoordinator;
|
|
44
|
+
server: LocalSubstrateNodeServerHandle;
|
|
45
|
+
close: () => Promise<void>;
|
|
46
|
+
}
|
|
47
|
+
declare function parseArgs(argv: readonly string[], env?: NodeJS.ProcessEnv): ParsedArgs;
|
|
48
|
+
declare function buildDescription(): HostDescription;
|
|
49
|
+
declare function startLocalSubstrateHost(options: StartHostOptions): Promise<LocalSubstrateHostHandle>;
|
|
50
|
+
export interface KeyRetryOptions {
|
|
51
|
+
/** Key resolver; defaults to resolveKey. Injectable for tests. */
|
|
52
|
+
resolve?: () => Promise<ResolvedKey | null>;
|
|
53
|
+
/** First backoff delay in ms. */
|
|
54
|
+
initialDelayMs?: number;
|
|
55
|
+
/** Backoff cap in ms. */
|
|
56
|
+
maxDelayMs?: number;
|
|
57
|
+
/** Total budget in ms; 0 (default) waits indefinitely. */
|
|
58
|
+
maxWaitMs?: number;
|
|
59
|
+
/** Clock; injectable for tests. */
|
|
60
|
+
now?: () => number;
|
|
61
|
+
/** Sleep; injectable for tests. */
|
|
62
|
+
sleep?: (ms: number) => Promise<void>;
|
|
63
|
+
/** Log sink; defaults to stderr. */
|
|
64
|
+
log?: (message: string) => void;
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* Resolve the signing key, polling with exponential backoff instead of failing
|
|
68
|
+
* on the first miss.
|
|
69
|
+
*
|
|
70
|
+
* A login-managed host can start before the login Keychain is unlocked, so the
|
|
71
|
+
* first resolveKey() returns null. Exiting there makes the process crash-loop
|
|
72
|
+
* under a KeepAlive supervisor, and any dependent that waits on the substrate
|
|
73
|
+
* hangs. Instead we stay alive and retry until the Keychain unlocks and the key
|
|
74
|
+
* resolves, so a reboot self-heals with no manual restart.
|
|
75
|
+
*
|
|
76
|
+
* Returns null only when a finite ATRIB_KEY_RETRY_MAX_MS budget is exhausted
|
|
77
|
+
* (default 0 = wait indefinitely), preserving an explicit-failure escape hatch.
|
|
78
|
+
*/
|
|
79
|
+
export declare function resolveSigningKeyWithRetry(options?: KeyRetryOptions): Promise<ResolvedKey | null>;
|
|
80
|
+
export declare const __test_only__: {
|
|
81
|
+
buildDescription: typeof buildDescription;
|
|
82
|
+
parseArgs: typeof parseArgs;
|
|
83
|
+
startLocalSubstrateHost: typeof startLocalSubstrateHost;
|
|
84
|
+
resolveSigningKeyWithRetry: typeof resolveSigningKeyWithRetry;
|
|
85
|
+
};
|
|
86
|
+
/**
|
|
87
|
+
* Run the local-substrate host entrypoint. Exists so forwarding bins (the
|
|
88
|
+
* @atrib/emit shim's `atrib-local-substrate`) can invoke the host without
|
|
89
|
+
* relying on the entrypoint guard above, which only fires when this file
|
|
90
|
+
* itself is process.argv[1].
|
|
91
|
+
*/
|
|
92
|
+
export declare function runLocalSubstrateHost(): Promise<void>;
|
|
93
|
+
export {};
|