pi-opa-net 0.1.0 → 0.2.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.
@@ -23,6 +23,12 @@ export interface EngineConfig {
23
23
  readonly hostname?: string;
24
24
  /** Calling session ID for metadata (pi/claude session). Empty if none. */
25
25
  readonly sessionId?: string;
26
+ /** Unlock keys from PIOPANET_UNLOCK_KEYS / --unlock (comma-separated env). */
27
+ readonly unlockKeys?: readonly string[];
28
+ /** Path to the deploy-local salt file (or PIOPANET_UNLOCK_SALT override). */
29
+ readonly unlockSaltPath?: string;
30
+ /** Agent ID for unlock audit metadata (PIOPANET_AGENT_ID). */
31
+ readonly unlockAgentId?: string;
26
32
  }
27
33
 
28
34
  const ENV = process.env;
@@ -63,9 +69,25 @@ function readdirSafe(path: string): string[] {
63
69
  export function configFromEnv(policyPath: string): EngineConfig {
64
70
  const failMode: FailMode = (ENV.PI_OPA_FAIL_MODE as FailMode) === 'closed' ? 'closed' : 'open';
65
71
  const timeoutMs = ENV.PI_OPA_TIMEOUT_MS ? Number.parseInt(ENV.PI_OPA_TIMEOUT_MS, 10) : 250;
66
- const cacheTtlMs = ENV.PI_OPA_CACHE_TTL_MS
72
+ const baseCacheTtlMs = ENV.PI_OPA_CACHE_TTL_MS
67
73
  ? Number.parseInt(ENV.PI_OPA_CACHE_TTL_MS, 10)
68
74
  : DEFAULT_CACHE_TTL_MS;
75
+
76
+ // Unlock keys: PIOPANET_UNLOCK_KEYS (comma-separated, trimmed).
77
+ const unlockKeys = (ENV.PIOPANET_UNLOCK_KEYS ?? '')
78
+ .split(',')
79
+ .map((k) => k.trim())
80
+ .filter((k) => k.length > 0);
81
+
82
+ // LD-G3: force cacheTtlMs=0 when unlock keys are present (cache poisoning guard).
83
+ const cacheTtlMs = unlockKeys.length > 0 ? 0 : baseCacheTtlMs;
84
+
85
+ // Salt path: PIOPANET_UNLOCK_SALT or PIOPANET_UNLOCK_SALT_FILE → default ~/.pi-opa-net/salt.
86
+ const unlockSaltPath =
87
+ ENV.PIOPANET_UNLOCK_SALT ?? ENV.PIOPANET_UNLOCK_SALT_FILE ?? defaultSaltPath();
88
+
89
+ const unlockAgentId = ENV.PIOPANET_AGENT_ID;
90
+
69
91
  return {
70
92
  opaBinary: resolveOpaBinary(),
71
93
  policyPath,
@@ -74,5 +96,14 @@ export function configFromEnv(policyPath: string): EngineConfig {
74
96
  cacheTtlMs,
75
97
  hostname: ENV.PI_OPA_HOSTNAME,
76
98
  sessionId: ENV.PI_OPA_SESSION_ID,
99
+ unlockKeys,
100
+ unlockSaltPath,
101
+ unlockAgentId,
77
102
  };
78
103
  }
104
+
105
+ /** Default salt file path: ~/.pi-opa-net/salt. */
106
+ function defaultSaltPath(): string {
107
+ const home = ENV.HOME ?? process.env.HOME ?? '';
108
+ return `${home}/.pi-opa-net/salt`;
109
+ }
@@ -4,13 +4,21 @@ import type { EngineConfig } from '../config/Config.ts';
4
4
  import type { EngineDecision, RawDeny } from '../engine/types.ts';
5
5
  import type { ParsedCommand } from '../parser/types.ts';
6
6
  import { type RuleMeta, type RuleRegistry, inferFamilyFromProgram } from '../rules/index.ts';
7
+ import type { UnlockResult } from '../unlock/types.ts';
7
8
 
8
9
  /** The schema-compliant decision output (decision-output.v1). */
9
10
  export interface DecisionOutput {
10
11
  readonly schema_version: '1.0';
11
12
  readonly decision: 'allow' | 'deny';
12
13
  readonly action: 'allow' | 'block' | 'prompt_user' | 'log_only';
13
- readonly source: 'opa' | 'fail-open' | 'fail-closed' | 'cached';
14
+ readonly source:
15
+ | 'opa'
16
+ | 'fail-open'
17
+ | 'fail-closed'
18
+ | 'cached'
19
+ | 'opa-unlocked'
20
+ | 'fail-open-keyless'
21
+ | 'unlock-filter-error';
14
22
  readonly reasons: readonly Reason[];
15
23
  readonly input: EvaluatedInput;
16
24
  readonly summary: string;
@@ -26,6 +34,11 @@ export interface Reason {
26
34
  readonly message: string;
27
35
  readonly family: string;
28
36
  readonly severity: 'block' | 'warn' | 'info';
37
+ readonly bypassed?: boolean;
38
+ readonly unlock_key_id?: string;
39
+ readonly unlock_key_type?: 'll' | 'ttl';
40
+ readonly unlock_expires_at?: string;
41
+ readonly unlock_status?: 'valid' | 'expired';
29
42
  }
30
43
 
31
44
  export interface EvaluatedInput {
@@ -43,6 +56,9 @@ export interface DecisionMetadata {
43
56
  readonly policy_path: string;
44
57
  readonly hostname: string;
45
58
  readonly session_id: string;
59
+ readonly unlock_count?: number;
60
+ readonly unlock_blocked_count?: number;
61
+ readonly unlock_agent?: string;
46
62
  }
47
63
 
48
64
  export interface DecisionBuilderDeps {
@@ -70,15 +86,49 @@ export class DecisionBuilder {
70
86
  this.deps = deps;
71
87
  }
72
88
 
73
- build(parsed: ParsedCommand, engine: EngineDecision): DecisionOutput {
74
- const reasons = engine.decision === 'deny' ? this.buildReasons(engine.reasons, parsed) : [];
89
+ build(
90
+ parsed: ParsedCommand,
91
+ engine: EngineDecision,
92
+ opts?: { unlockResult?: UnlockResult },
93
+ ): DecisionOutput {
94
+ const unlockResult = opts?.unlockResult;
95
+ const reasons =
96
+ engine.decision === 'deny' ? this.buildReasons(engine.reasons, parsed, unlockResult) : [];
75
97
  const suggestions = this.collectSuggestions(reasons);
76
- const action = engine.decision === 'allow' ? 'allow' : 'block';
98
+
99
+ // Determine source and decision based on unlock result.
100
+ let source: DecisionOutput['source'] = engine.source;
101
+ let decision = engine.decision;
102
+ if (unlockResult && unlockResult.bypassedCount > 0 && unlockResult.blockedCount === 0) {
103
+ source = 'opa-unlocked';
104
+ decision = 'allow';
105
+ }
106
+
107
+ const action = decision === 'allow' ? 'allow' : 'block';
108
+
109
+ // Build metadata with optional unlock fields.
110
+ const metadata: DecisionMetadata = {
111
+ engine: 'opa',
112
+ opa_version: engine.opaVersion,
113
+ rulebook_digest: this.deps.digest,
114
+ policy_path: this.deps.config.policyPath,
115
+ hostname: this.deps.config.hostname ?? osHostname(),
116
+ session_id: this.deps.config.sessionId ?? '',
117
+ };
118
+ if (unlockResult) {
119
+ const meta = metadata as unknown as Record<string, unknown>;
120
+ meta.unlock_count = unlockResult.bypassedCount;
121
+ meta.unlock_blocked_count = unlockResult.blockedCount;
122
+ if (this.deps.config.unlockAgentId) {
123
+ meta.unlock_agent = this.deps.config.unlockAgentId;
124
+ }
125
+ }
126
+
77
127
  return {
78
128
  schema_version: '1.0',
79
- decision: engine.decision,
129
+ decision,
80
130
  action,
81
- source: engine.source,
131
+ source,
82
132
  reasons,
83
133
  input: {
84
134
  raw: parsed.raw,
@@ -89,29 +139,50 @@ export class DecisionBuilder {
89
139
  },
90
140
  summary: this.summary(engine, parsed, reasons),
91
141
  suggestions,
92
- metadata: {
93
- engine: 'opa',
94
- opa_version: engine.opaVersion,
95
- rulebook_digest: this.deps.digest,
96
- policy_path: this.deps.config.policyPath,
97
- hostname: this.deps.config.hostname ?? osHostname(),
98
- session_id: this.deps.config.sessionId ?? '',
99
- },
142
+ metadata,
100
143
  evaluated_at: (this.deps.now ?? (() => new Date()))().toISOString(),
101
144
  decision_id: (this.deps.uuid ?? randomUUID)(),
102
145
  duration_ms: engine.durationMs,
103
146
  };
104
147
  }
105
148
 
106
- private buildReasons(raw: readonly RawDeny[], parsed: ParsedCommand): Reason[] {
107
- return raw.map((d) => {
108
- const meta = this.deps.registry.lookup(d);
109
- return {
149
+ private buildReasons(
150
+ raw: readonly RawDeny[],
151
+ parsed: ParsedCommand,
152
+ unlockResult?: UnlockResult,
153
+ ): Reason[] {
154
+ return raw.map((d, i) => {
155
+ const familyHint = inferFamilyFromProgram(parsed.program);
156
+ const meta = this.deps.registry.lookup(d, familyHint, parsed.args);
157
+ const reason: Record<string, unknown> = {
110
158
  rule_id: meta.ruleId,
111
159
  message: meta.message,
112
160
  family: this.resolveFamily(meta, parsed),
113
- severity: 'block' as const,
161
+ severity: 'block',
114
162
  };
163
+
164
+ // Merge unlock info if available.
165
+ if (unlockResult && i < unlockResult.reasons.length) {
166
+ const info = unlockResult.reasons[i];
167
+ if (info.bypassed) {
168
+ reason.bypassed = true;
169
+ reason.severity = 'info'; // Demote bypassed reason severity.
170
+ }
171
+ const keyId = info.unlockKeyId ?? info.unlock_key_id;
172
+ if (keyId) reason.unlock_key_id = keyId;
173
+ const keyType = info.keyType ?? info.unlock_key_type;
174
+ if (keyType) reason.unlock_key_type = keyType;
175
+ const exp = info.expiresAt ?? info.unlock_expires_at;
176
+ if (typeof exp === 'number') {
177
+ reason.unlock_expires_at = new Date(exp * 1000).toISOString();
178
+ } else if (typeof exp === 'string') {
179
+ reason.unlock_expires_at = exp;
180
+ }
181
+ const status = info.unlockStatus ?? info.unlock_status;
182
+ if (status) reason.unlock_status = status;
183
+ }
184
+
185
+ return reason as unknown as Reason;
115
186
  });
116
187
  }
117
188
 
@@ -10,7 +10,10 @@ export type RuleFamily =
10
10
  | 'glab'
11
11
  | 'bd'
12
12
  | 'builtin'
13
- | 'custom';
13
+ | 'custom'
14
+ | 'tmux'
15
+ | 'pkill'
16
+ | 'killall';
14
17
 
15
18
  export interface RuleMeta {
16
19
  readonly ruleId: string;
@@ -18,6 +21,15 @@ export interface RuleMeta {
18
21
  readonly message: string;
19
22
  /** Safe alternatives for the "did you mean?" UX. Optional. */
20
23
  readonly suggestions?: readonly string[];
24
+ /**
25
+ * Disambiguation tokens used ONLY when multiple registered rules share an
26
+ * identical message AND family (collision). If any of these tokens appears
27
+ * in the parsed command's args, this candidate wins. Only needed for rules
28
+ * whose reason text is byte-identical to a sibling rule's (e.g.
29
+ * block-tmux-kill-server vs block-tmux-kill-session, which share the same
30
+ * verbatim reason and family 'tmux').
31
+ */
32
+ readonly matchArgs?: readonly string[];
21
33
  }
22
34
 
23
35
  /**
@@ -28,20 +40,51 @@ export interface RuleMeta {
28
40
  * so audits trace decision → rule → source line. Messages not in the registry
29
41
  * fall back to a synthesized `custom:<hash>` id with family=custom.
30
42
  *
43
+ * Multiple rules MAY share an identical message (e.g. the four
44
+ * tmux/pkill/killall session-kill rules all carry the same verbatim reason
45
+ * text). Such collisions are stored as a list and resolved by family hint
46
+ * (inferred from the parsed program) when one is supplied to lookup().
47
+ *
31
48
  * Keeping this in TS (not rego) is intentional: rego is the policy, this is the
32
49
  * provenance metadata layer. DRY — one canonical list, consumed by the builder.
33
50
  */
34
51
  export class RuleRegistry {
35
- private readonly byMessage: Map<string, RuleMeta>;
52
+ private readonly byMessage: Map<string, RuleMeta[]>;
36
53
 
37
54
  constructor(rules: readonly RuleMeta[]) {
38
- this.byMessage = new Map(rules.map((r) => [r.message, r]));
55
+ this.byMessage = new Map<string, RuleMeta[]>();
56
+ for (const r of rules) {
57
+ const arr = this.byMessage.get(r.message) ?? [];
58
+ arr.push(r);
59
+ this.byMessage.set(r.message, arr);
60
+ }
39
61
  }
40
62
 
41
- /** Look up metadata for a deny message; synthesizes a custom entry if unknown. */
42
- lookup(deny: RawDeny): RuleMeta {
43
- const found = this.byMessage.get(deny.message);
44
- if (found) return found;
63
+ /**
64
+ * Look up metadata for a deny message; synthesizes a custom entry if unknown.
65
+ *
66
+ * Collision resolution order when several registered rules share the same
67
+ * message:
68
+ * 1. `familyHint` (inferred from the parsed program) narrows to that family.
69
+ * 2. `parsedArgs` narrows further to a candidate whose `matchArgs` intersects
70
+ * the parsed args (for rules that share message AND family, e.g. the two
71
+ * tmux kill-* rules).
72
+ * 3. Otherwise the first remaining candidate is returned.
73
+ */
74
+ lookup(deny: RawDeny, familyHint?: string, parsedArgs?: readonly string[]): RuleMeta {
75
+ const candidates = this.byMessage.get(deny.message);
76
+ if (candidates && candidates.length > 0) {
77
+ let pool = candidates;
78
+ if (familyHint) {
79
+ const byFamily = pool.filter((c) => c.family === familyHint);
80
+ if (byFamily.length > 0) pool = byFamily;
81
+ }
82
+ if (parsedArgs && pool.length > 1) {
83
+ const byArgs = pool.filter((c) => c.matchArgs?.some((t) => parsedArgs.includes(t)));
84
+ if (byArgs.length > 0) pool = byArgs;
85
+ }
86
+ return pool[0];
87
+ }
45
88
  return {
46
89
  ruleId: `custom:${hashMessage(deny.message)}`,
47
90
  family: 'custom',
@@ -1,4 +1,4 @@
1
- import type { RuleMeta } from './RuleRegistry.ts';
1
+ import type { RuleFamily, RuleMeta } from './RuleRegistry.ts';
2
2
 
3
3
  /**
4
4
  * Canonical rule catalog — mirrors policy/safety.rego message-for-message.
@@ -206,15 +206,51 @@ export const RULES: readonly RuleMeta[] = [
206
206
  family: 'glab',
207
207
  message: 'Public GitLab repository creation is blocked by default.',
208
208
  },
209
+ // ── GROUP G: tmux / pkill / killall session protection (cc-safety-net parity) ──
210
+ {
211
+ ruleId: 'block-tmux-kill-server',
212
+ family: 'tmux',
213
+ message:
214
+ 'Killing the tmux/wezterm server destroys ALL sessions, panes, and in-flight work across every client. Do NOT run this automatically \u2014 hand the exact command back to the user and let them run it themselves.',
215
+ matchArgs: ['kill-server'],
216
+ },
217
+ {
218
+ ruleId: 'block-tmux-kill-session',
219
+ family: 'tmux',
220
+ message:
221
+ 'Killing the tmux/wezterm server destroys ALL sessions, panes, and in-flight work across every client. Do NOT run this automatically \u2014 hand the exact command back to the user and let them run it themselves.',
222
+ matchArgs: ['kill-session'],
223
+ },
224
+ {
225
+ ruleId: 'block-pkill-tmux-wezterm',
226
+ family: 'pkill',
227
+ message:
228
+ 'Killing the tmux/wezterm server destroys ALL sessions, panes, and in-flight work across every client. Do NOT run this automatically \u2014 hand the exact command back to the user and let them run it themselves.',
229
+ },
230
+ {
231
+ ruleId: 'block-killall-tmux-wezterm',
232
+ family: 'killall',
233
+ message:
234
+ 'Killing the tmux/wezterm server destroys ALL sessions, panes, and in-flight work across every client. Do NOT run this automatically \u2014 hand the exact command back to the user and let them run it themselves.',
235
+ },
209
236
  ];
210
237
 
211
- /** gcloud/bq produce sprintf messages — family inferred from program. */
212
- export function inferFamilyFromProgram(program: string): 'gcloud' | 'bq' | 'custom' {
238
+ /** gcloud/bq produce sprintf messages — family inferred from program.
239
+ * tmux/pkill/killall rules share identical reason text (the four session-kill
240
+ * rules), so their family is also inferred from the program to disambiguate
241
+ * the message-keyed registry. */
242
+ export function inferFamilyFromProgram(program: string): RuleFamily {
213
243
  switch (program) {
214
244
  case 'gcloud':
215
245
  return 'gcloud';
216
246
  case 'bq':
217
247
  return 'bq';
248
+ case 'tmux':
249
+ return 'tmux';
250
+ case 'pkill':
251
+ return 'pkill';
252
+ case 'killall':
253
+ return 'killall';
218
254
  default:
219
255
  return 'custom';
220
256
  }
@@ -0,0 +1,28 @@
1
+ import { createHmac } from 'node:crypto';
2
+
3
+ /**
4
+ * Salted HMAC-SHA256 key derivation [D2].
5
+ *
6
+ * `derive(salt, rule_id) = HMAC-SHA256(salt, rule_id).hex().slice(0, 16)`
7
+ *
8
+ * 16 hex = 64-bit mac. Salt defeats rainbow tables (rule_ids are public).
9
+ * HMAC (not bare SHA) so salt stays secret under known-message attack.
10
+ */
11
+ export class KeyDerivation {
12
+ // Static-only utility class — private constructor prevents instantiation.
13
+ private constructor() {}
14
+
15
+ /**
16
+ * Derive a 16-hex (64-bit) mac from salt + rule_id.
17
+ * @throws if rule_id or salt is empty.
18
+ */
19
+ static derive(salt: Buffer, ruleId: string): string {
20
+ if (!ruleId) {
21
+ throw new Error('rule_id must not be empty');
22
+ }
23
+ if (salt.length === 0) {
24
+ throw new Error('salt must not be empty');
25
+ }
26
+ return createHmac('sha256', salt).update(ruleId).digest('hex').slice(0, 16);
27
+ }
28
+ }
@@ -0,0 +1,33 @@
1
+ import type { ParsedKey } from './types.ts';
2
+
3
+ /**
4
+ * Parse self-describing key strings [D3].
5
+ *
6
+ * - Long-lived: `ll_<16hex>` → { type:'ll', mac }
7
+ * - TTL: `ttl.<unix-exp-sec>.<16hex>` → { type:'ttl', exp, mac }
8
+ *
9
+ * Returns null for any malformed input. Hex MUST be lowercase.
10
+ */
11
+ export class KeyParser {
12
+ // Static-only utility class — private constructor prevents instantiation.
13
+ private constructor() {}
14
+
15
+ private static readonly LL_RE = /^ll_([a-f0-9]{16})$/;
16
+ private static readonly TTL_RE = /^ttl\.(\d+)\.([a-f0-9]{16})$/;
17
+
18
+ static parse(raw: string): ParsedKey | null {
19
+ if (typeof raw !== 'string') return null;
20
+
21
+ const ll = raw.match(KeyParser.LL_RE);
22
+ if (ll) {
23
+ return { type: 'll', mac: ll[1] };
24
+ }
25
+
26
+ const ttl = raw.match(KeyParser.TTL_RE);
27
+ if (ttl) {
28
+ return { type: 'ttl', exp: Number.parseInt(ttl[1], 10), mac: ttl[2] };
29
+ }
30
+
31
+ return null;
32
+ }
33
+ }
@@ -0,0 +1,74 @@
1
+ import { RULES } from '../rules/index.ts';
2
+ import { KeyDerivation } from './KeyDerivation.ts';
3
+ import { KeyParser } from './KeyParser.ts';
4
+ import type { VerifyResult } from './types.ts';
5
+
6
+ /**
7
+ * Verify a raw key string against a rule_id + salt [D2][D3].
8
+ *
9
+ * LL: valid iff `derive(salt, rule_id) === parsed.mac`.
10
+ * TTL: valid iff `derive(salt, rule_id + '.' + str(exp)) === parsed.mac`
11
+ * AND `now/1000 <= exp` (strict, no skew tolerance).
12
+ *
13
+ * When the mac does not match, wrong-rule vs wrong-salt is disambiguated by
14
+ * re-deriving against every catalog rule_id with the given salt. If any
15
+ * known rule matches → wrong-rule; otherwise → wrong-salt.
16
+ */
17
+ export class KeyVerifier {
18
+ // Static-only utility class — private constructor prevents instantiation.
19
+ private constructor() {}
20
+
21
+ /**
22
+ * @param rawKey Key string (ll_… or ttl.…) from the agent.
23
+ * @param ruleId The rule_id to check against.
24
+ * @param salt Deploy-local salt buffer.
25
+ * @param nowMs Verifier process clock in milliseconds (Date.now()).
26
+ */
27
+ static verify(rawKey: string, ruleId: string, salt: Buffer, nowMs: number): VerifyResult {
28
+ const parsed = KeyParser.parse(rawKey);
29
+ if (!parsed) {
30
+ return { valid: false, reason: 'malformed' };
31
+ }
32
+
33
+ const input =
34
+ parsed.type === 'ttl' && parsed.exp !== undefined ? `${ruleId}.${parsed.exp}` : ruleId;
35
+ const derived = KeyDerivation.derive(salt, input);
36
+
37
+ if (derived === parsed.mac) {
38
+ // Mac matches — check TTL clock.
39
+ if (parsed.type === 'ttl' && parsed.exp !== undefined) {
40
+ const nowSec = nowMs / 1000;
41
+ if (nowSec > parsed.exp) {
42
+ return {
43
+ valid: false,
44
+ reason: 'expired',
45
+ keyType: 'ttl',
46
+ expiresAt: parsed.exp,
47
+ unlockStatus: 'expired',
48
+ };
49
+ }
50
+ return {
51
+ valid: true,
52
+ keyType: 'ttl',
53
+ expiresAt: parsed.exp,
54
+ unlockStatus: 'valid',
55
+ };
56
+ }
57
+ return { valid: true, keyType: 'll', unlockStatus: 'valid' };
58
+ }
59
+
60
+ // Mac mismatch — try every catalog rule with the given salt.
61
+ for (const rule of RULES) {
62
+ const tryInput =
63
+ parsed.type === 'ttl' && parsed.exp !== undefined
64
+ ? `${rule.ruleId}.${parsed.exp}`
65
+ : rule.ruleId;
66
+ const tryMac = KeyDerivation.derive(salt, tryInput);
67
+ if (tryMac === parsed.mac) {
68
+ return { valid: false, reason: 'wrong-rule', keyType: parsed.type };
69
+ }
70
+ }
71
+
72
+ return { valid: false, reason: 'wrong-salt', keyType: parsed.type };
73
+ }
74
+ }
@@ -0,0 +1,98 @@
1
+ import { randomBytes } from 'node:crypto';
2
+ import { existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from 'node:fs';
3
+ import { dirname, join } from 'node:path';
4
+
5
+ /**
6
+ * Salt resolution seam [D4 / LD-Y1].
7
+ *
8
+ * Precedence (first wins):
9
+ * 1. `PIOPANET_UNLOCK_SALT` env — literal value (for testing).
10
+ * 2. `saltPath` constructor option or `PIOPANET_UNLOCK_SALT_FILE` env — file path.
11
+ * 3. Auto-generate 32 random bytes at the file path (atomic `wx`, mode 0o600).
12
+ *
13
+ * On read, warns to stderr if the file mode is not 0o600.
14
+ */
15
+ export interface SaltResolverOptions {
16
+ /** Explicit salt file path (typically from config.unlockSaltPath). */
17
+ readonly saltPath?: string;
18
+ }
19
+
20
+ const ENV = process.env;
21
+
22
+ export class SaltResolver {
23
+ private readonly saltPath?: string;
24
+
25
+ constructor(opts: SaltResolverOptions = {}) {
26
+ this.saltPath = opts.saltPath ?? ENV.PIOPANET_UNLOCK_SALT_FILE ?? defaultSaltPath();
27
+ }
28
+
29
+ resolve(): Buffer {
30
+ // 1. PIOPANET_UNLOCK_SALT env — literal value OR path to an existing file.
31
+ // If the value points to an existing file, read it; otherwise treat as literal.
32
+ const envSalt = ENV.PIOPANET_UNLOCK_SALT;
33
+ if (envSalt !== undefined) {
34
+ try {
35
+ const stat = statSync(envSalt);
36
+ if (stat.isFile()) {
37
+ warnIfBadMode(envSalt);
38
+ return readFileSync(envSalt);
39
+ }
40
+ } catch {
41
+ // Not an existing file path — fall through to literal.
42
+ }
43
+ return Buffer.from(envSalt);
44
+ }
45
+
46
+ const path = this.saltPath ?? defaultSaltPath();
47
+
48
+ // 2. Existing file — read + warn on bad mode.
49
+ if (existsSync(path)) {
50
+ warnIfBadMode(path);
51
+ return readFileSync(path);
52
+ }
53
+
54
+ // 3. Auto-generate atomically (O_CREAT | O_EXCL via flag:'wx').
55
+ ensureDir(path);
56
+ const generated = randomBytes(32);
57
+ try {
58
+ writeFileSync(path, generated, { flag: 'wx', mode: 0o600 });
59
+ return generated;
60
+ } catch (e) {
61
+ // Race: another process created the file between our existsSync and writeFileSync.
62
+ // Re-read the winner's file (no last-write-wins).
63
+ const err = e as NodeJS.ErrnoException;
64
+ if (err.code === 'EEXIST' && existsSync(path)) {
65
+ warnIfBadMode(path);
66
+ return readFileSync(path);
67
+ }
68
+ throw e;
69
+ }
70
+ }
71
+ }
72
+
73
+ function defaultSaltPath(): string {
74
+ const home = ENV.HOME ?? process.env.HOME ?? '';
75
+ return join(home, '.pi-opa-net', 'salt');
76
+ }
77
+
78
+ function ensureDir(path: string): void {
79
+ const dir = dirname(path);
80
+ try {
81
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
82
+ } catch {
83
+ // Directory may already exist — ignore.
84
+ }
85
+ }
86
+
87
+ function warnIfBadMode(path: string): void {
88
+ try {
89
+ const mode = statSync(path).mode & 0o777;
90
+ if (mode !== 0o600) {
91
+ process.stderr.write(
92
+ `warning: salt file ${path} has mode ${mode.toString(8)} (expected 0o600); other users on this host can read it and derive unlock keys.\n`,
93
+ );
94
+ }
95
+ } catch {
96
+ // Stat failed — nothing to warn about.
97
+ }
98
+ }