@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/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 {};