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.
- package/CHANGELOG.md +42 -0
- package/README.md +48 -4
- package/bin/pi-opa-net.js +88 -7
- package/package.json +5 -2
- package/policy/safety.rego +36 -0
- package/schemas/decision-output.v1.json +41 -3
- package/src/audit/AuditSink.ts +26 -0
- package/src/cli/run.ts +92 -2
- package/src/cli/unlock-key.ts +52 -0
- package/src/config/Config.ts +32 -1
- package/src/output/DecisionBuilder.ts +90 -19
- package/src/rules/RuleRegistry.ts +50 -7
- package/src/rules/catalog.ts +39 -3
- package/src/unlock/KeyDerivation.ts +28 -0
- package/src/unlock/KeyParser.ts +33 -0
- package/src/unlock/KeyVerifier.ts +74 -0
- package/src/unlock/SaltResolver.ts +98 -0
- package/src/unlock/UnlockFilter.ts +107 -0
- package/src/unlock/types.ts +58 -0
package/src/config/Config.ts
CHANGED
|
@@ -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
|
|
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:
|
|
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(
|
|
74
|
-
|
|
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
|
-
|
|
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
|
|
129
|
+
decision,
|
|
80
130
|
action,
|
|
81
|
-
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(
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
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'
|
|
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
|
|
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
|
-
/**
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
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',
|
package/src/rules/catalog.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
+
}
|