pi-opa-net 0.1.0 → 0.3.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.
@@ -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
+ }
@@ -0,0 +1,107 @@
1
+ import type { RawDeny } from '../engine/types.ts';
2
+ import type { RuleRegistry } from '../rules/index.ts';
3
+ import { KeyParser } from './KeyParser.ts';
4
+ import { KeyVerifier } from './KeyVerifier.ts';
5
+ import type { UnlockReasonInfo, UnlockResult, VerifyResult } from './types.ts';
6
+
7
+ /**
8
+ * All-or-nothing unlock filter [D5 / LD-G6].
9
+ *
10
+ * For each deny reason (severity:block), checks if ANY presented key validly
11
+ * unlocks that reason's rule_id. `allow ⟺ every block reason has a valid key`.
12
+ *
13
+ * Expired TTL keys are reported (unlock_status:'expired') but do NOT bypass.
14
+ * `unlock_key_id` is always the first 8 hex of the matching key's mac — the
15
+ * full 16-hex key NEVER appears in the result.
16
+ */
17
+ export class UnlockFilter {
18
+ // Static-only utility class — private constructor prevents instantiation.
19
+ private constructor() {}
20
+
21
+ /**
22
+ * @param reasons Raw deny reasons from the engine.
23
+ * @param keys Raw key strings presented by the agent.
24
+ * @param salt Deploy-local salt buffer.
25
+ * @param nowMs Verifier process clock in ms (Date.now()).
26
+ * @param registry Rule registry for message → rule_id lookup.
27
+ */
28
+ static filter(
29
+ reasons: readonly RawDeny[],
30
+ keys: readonly string[],
31
+ salt: Buffer,
32
+ nowMs: number,
33
+ registry: RuleRegistry,
34
+ ): UnlockResult {
35
+ const infos: UnlockReasonInfo[] = reasons.map((deny) => {
36
+ const meta = registry.lookup(deny);
37
+ const ruleId = meta.ruleId;
38
+
39
+ let validResult: VerifyResult | null = null;
40
+ let expiredResult: VerifyResult | null = null;
41
+ let validKey: string | null = null;
42
+
43
+ for (const key of keys) {
44
+ const result = KeyVerifier.verify(key, ruleId, salt, nowMs);
45
+ if (result.valid) {
46
+ validResult = result;
47
+ validKey = key;
48
+ break;
49
+ }
50
+ if (result.reason === 'expired' && !expiredResult) {
51
+ expiredResult = result;
52
+ }
53
+ }
54
+
55
+ if (validResult) {
56
+ const parsed = KeyParser.parse(validKey!);
57
+ const fullMac = parsed?.mac ?? '';
58
+ const keyId = fullMac.slice(0, 8);
59
+ const keyType = validResult.keyType;
60
+ const expiresAt = validResult.expiresAt;
61
+ return {
62
+ message: deny.message,
63
+ ruleId,
64
+ bypassed: true,
65
+ unlockKeyId: keyId,
66
+ keyType,
67
+ expiresAt,
68
+ unlockStatus: 'valid',
69
+ unlock_key_id: keyId,
70
+ unlock_key_type: keyType,
71
+ unlock_expires_at: expiresAt,
72
+ unlock_status: 'valid',
73
+ };
74
+ }
75
+
76
+ if (expiredResult) {
77
+ return {
78
+ message: deny.message,
79
+ ruleId,
80
+ bypassed: false,
81
+ unlockKeyId: '',
82
+ unlock_key_id: '',
83
+ unlockStatus: 'expired',
84
+ unlock_status: 'expired',
85
+ };
86
+ }
87
+
88
+ return {
89
+ message: deny.message,
90
+ ruleId,
91
+ bypassed: false,
92
+ unlockKeyId: '',
93
+ unlock_key_id: '',
94
+ };
95
+ });
96
+
97
+ const bypassedCount = infos.filter((r) => r.bypassed).length;
98
+ const blockedCount = infos.length - bypassedCount;
99
+
100
+ return {
101
+ allow: blockedCount === 0,
102
+ bypassedCount,
103
+ blockedCount,
104
+ reasons: infos,
105
+ };
106
+ }
107
+ }
@@ -0,0 +1,58 @@
1
+ /**
2
+ * Shared types for the unlock subsystem [D1-D11].
3
+ *
4
+ * These types are the contract between UnlockFilter → DecisionBuilder.
5
+ * Internal (TS-side) property naming uses camelCase; the DecisionBuilder
6
+ * translates to snake_case schema fields at output time.
7
+ */
8
+
9
+ /** Result of parsing a raw key string. */
10
+ export interface ParsedKey {
11
+ readonly type: 'll' | 'ttl';
12
+ readonly mac: string;
13
+ /** Unix expiry in seconds. Present only for TTL keys. */
14
+ readonly exp?: number;
15
+ }
16
+
17
+ /** Outcome of verifying a parsed key against a rule_id + salt. */
18
+ export interface VerifyResult {
19
+ readonly valid: boolean;
20
+ readonly reason?: 'malformed' | 'wrong-rule' | 'wrong-salt' | 'expired';
21
+ readonly keyType?: 'll' | 'ttl';
22
+ /** Unix expiry in seconds (TTL only). */
23
+ readonly expiresAt?: number;
24
+ readonly unlockStatus?: 'valid' | 'expired';
25
+ }
26
+
27
+ /**
28
+ * Per-reason unlock info produced by UnlockFilter.
29
+ *
30
+ * Carries BOTH camelCase (consumed by DecisionBuilder) and snake_case
31
+ * (consumed by UnlockFilter test assertions) aliases so the same object
32
+ * satisfies both consumers without conversion.
33
+ */
34
+ export interface UnlockReasonInfo {
35
+ readonly message: string;
36
+ readonly ruleId: string;
37
+ readonly bypassed: boolean;
38
+
39
+ /** camelCase aliases — DecisionBuilder reads these. */
40
+ readonly unlockKeyId: string;
41
+ readonly keyType?: 'll' | 'ttl';
42
+ readonly expiresAt?: number;
43
+ readonly unlockStatus?: 'valid' | 'expired';
44
+
45
+ /** snake_case aliases — schema-compatible / UnlockFilter assertions. */
46
+ readonly unlock_key_id: string;
47
+ readonly unlock_key_type?: 'll' | 'ttl';
48
+ readonly unlock_expires_at?: number;
49
+ readonly unlock_status?: 'valid' | 'expired';
50
+ }
51
+
52
+ /** Aggregate result of filtering reasons through unlock keys. */
53
+ export interface UnlockResult {
54
+ readonly allow: boolean;
55
+ readonly bypassedCount: number;
56
+ readonly blockedCount: number;
57
+ readonly reasons: readonly UnlockReasonInfo[];
58
+ }