@twinsearth/w2m-dsh-plugin 1.2.5 → 1.2.6

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,69 @@
1
+ /**
2
+ * Envelope signatures: what a machine signed, and how a reader checks it.
3
+ *
4
+ * ## Why the payload is exactly four fields
5
+ *
6
+ * A signature over "the whole envelope" is impossible to verify reproducibly: the envelope carries hashes, timestamps and
7
+ * transport facts that change between the signing machine and the reader. So the machine signs the four facts that identify
8
+ * THE COMMAND: task id, argv hash (the envelope's command_hash), machine id, and when it signed. Everything else in the
9
+ * envelope is covered indirectly through those, and a reader verifies exactly this tuple.
10
+ *
11
+ * ## Why verification returns a status rather than throwing
12
+ *
13
+ * "unsigned" is not an error -- a machine running an older build, or one configured without a device key, is a fact to report.
14
+ * "bad_signature" is the interesting case: the data does not match the signature, which is exactly what tampering looks like.
15
+ * A caller must see the difference, so both are values.
16
+ */
17
+ import { createPublicKey, verify } from 'node:crypto';
18
+
19
+ /** The DER prefix for an Ed25519 SubjectPublicKeyInfo; the 32 raw key bytes follow. */
20
+ const ED25519_SPKI_PREFIX = Buffer.from('302a300506032b6570032100', 'hex');
21
+
22
+ /**
23
+ * The canonical bytes a machine signs. Sorted keys, no whitespace, so the same tuple always produces the same bytes.
24
+ *
25
+ * @param {{task_id: string, argv_hash: string, machine_id: string, ts: string}} input
26
+ * @returns {string}
27
+ */
28
+ export function canonicalSignedPayload({ task_id: taskId, argv_hash: argvHash, machine_id: machineId, ts }) {
29
+ return JSON.stringify({
30
+ argv_hash: String(argvHash ?? ''),
31
+ machine_id: String(machineId ?? ''),
32
+ task_id: String(taskId ?? ''),
33
+ ts: String(ts ?? ''),
34
+ });
35
+ }
36
+
37
+ /**
38
+ * @param {string|null} publicKeyBase64
39
+ * @returns {import('node:crypto').KeyObject|null}
40
+ */
41
+ export function publicKeyFromBase64(publicKeyBase64) {
42
+ if (typeof publicKeyBase64 !== 'string' || publicKeyBase64 === '') return null;
43
+ try {
44
+ const raw = Buffer.from(publicKeyBase64, 'base64');
45
+ if (raw.length !== 32) return null;
46
+ return createPublicKey({ key: Buffer.concat([ED25519_SPKI_PREFIX, raw]), format: 'der', type: 'spki' });
47
+ } catch {
48
+ return null;
49
+ }
50
+ }
51
+
52
+ /**
53
+ * @param {{signature?: object|null, task_id?: string, argv_hash?: string, machine_id?: string, ts?: string}} input
54
+ * @returns {'signed'|'unsigned'|'bad_signature'|'unsupported_algorithm'}
55
+ */
56
+ export function verifyEnvelopeSignature({ signature = null, task_id: taskId, argv_hash: argvHash, machine_id: machineId, ts } = {}) {
57
+ if (signature === null || signature === undefined || typeof signature !== 'object') return 'unsigned';
58
+ if (signature.alg !== 'ed25519') return 'unsupported_algorithm';
59
+ if (typeof signature.value !== 'string' || signature.value === '') return 'bad_signature';
60
+ const key = publicKeyFromBase64(signature.public_key);
61
+ if (key === null) return 'bad_signature';
62
+ const payload = canonicalSignedPayload({ task_id: taskId, argv_hash: argvHash, machine_id: machineId, ts: signature.ts ?? ts });
63
+ try {
64
+ const ok = verify(null, Buffer.from(payload, 'utf8'), key, Buffer.from(signature.value, 'base64'));
65
+ return ok ? 'signed' : 'bad_signature';
66
+ } catch {
67
+ return 'bad_signature';
68
+ }
69
+ }
@@ -0,0 +1,65 @@
1
+ /**
2
+ * The permission matrix: which fleet role may perform which action.
3
+ *
4
+ * ## Why a table, and why refusals are named
5
+ *
6
+ * A role check scattered across call sites drifts: one route checks, another forgets, and the matrix exists only in a document.
7
+ * This table is the single answer, and a refusal always names the action, the role required and the role held -- an
8
+ * "access denied" without those three facts is a support ticket, not a diagnostic.
9
+ *
10
+ * ## Why the levels are ordinal
11
+ *
12
+ * Roles form an order (observer < worker < owner). Stating a minimum level rather than enumerating every allowed role means a
13
+ * new role slots in between two existing ones without rewriting every action.
14
+ */
15
+ export const FLEET_ACTIONS = Object.freeze(['read_private', 'vote', 'join', 'dispatch', 'approve', 'manage_members']);
16
+
17
+ /** Lower number = fewer rights. */
18
+ export const ROLE_LEVEL = Object.freeze({ observer: 0, worker: 1, owner: 2 });
19
+
20
+ /** The minimum level an action needs. `manage_members` is owner-only; approving is owner-only for the same reason. */
21
+ export const ACTION_MIN_LEVEL = Object.freeze({
22
+ read_private: 0,
23
+ vote: 1,
24
+ join: 1,
25
+ dispatch: 1,
26
+ approve: 2,
27
+ manage_members: 2,
28
+ });
29
+
30
+ /** The role a level maps back to, for a refusal that says which role would have been enough. */
31
+ export function roleForLevel(level) {
32
+ const best = Object.entries(ROLE_LEVEL).filter(([, value]) => value === level)[0];
33
+ return best === undefined ? null : best[0];
34
+ }
35
+
36
+ /**
37
+ * @param {{role?: string|null, action: string}} input
38
+ * @returns {{ok: true, action: string, role: string}} | {ok: false, code: 'FORBIDDEN_ACTION', action: string, required_level: number, current_level: number, required_role: string|null, current_role: string}}
39
+ */
40
+ export function can({ role = null, action } = {}) {
41
+ const currentRole = typeof role === 'string' && role in ROLE_LEVEL ? role : 'worker'; // unknown device = worker, never owner
42
+ if (!FLEET_ACTIONS.includes(action)) {
43
+ return {
44
+ ok: false, code: 'FORBIDDEN_ACTION', action, required_level: null, current_level: ROLE_LEVEL[currentRole],
45
+ required_role: null, current_role: currentRole,
46
+ };
47
+ }
48
+ const requiredLevel = ACTION_MIN_LEVEL[action];
49
+ const currentLevel = ROLE_LEVEL[currentRole];
50
+ if (currentLevel >= requiredLevel) return { ok: true, action, role: currentRole };
51
+ return {
52
+ ok: false,
53
+ code: 'FORBIDDEN_ACTION',
54
+ action,
55
+ required_level: requiredLevel,
56
+ current_level: currentLevel,
57
+ required_role: roleForLevel(requiredLevel),
58
+ current_role: currentRole,
59
+ };
60
+ }
61
+
62
+ /** Every action with its verdict for one role -- the board an operator reads. */
63
+ export function matrixFor(role) {
64
+ return FLEET_ACTIONS.map((action) => ({ action, ...can({ role, action }) }));
65
+ }
@@ -0,0 +1,70 @@
1
+ /**
2
+ * Output redaction: secrets a command prints are replaced before they leave this machine.
3
+ *
4
+ * ## Why a replacement, not a deletion
5
+ *
6
+ * Deleting the line would keep the secret out of the envelope and also destroy the evidence: line numbers and structure are
7
+ * what make a failure reproducible. So each match becomes [redacted:class] in place.
8
+ *
9
+ * ## Why nothing here touches a hash
10
+ *
11
+ * stdout_sha256 and stdout_bytes cover the RAW bytes and are computed by the executor. Redacting the text must not feed back
12
+ * into them: the envelope's hashes stay evidence, and only the human-readable heads/tails are filtered. The same is true of
13
+ * blobs -- a blob's name is the hash of its bytes, and rewriting them would make the name a lie.
14
+ *
15
+ * ## Why it can be turned off
16
+ *
17
+ * An operator debugging a credential problem needs the original. Turning it off is allowed and SAYS SO (unredacted: true in the
18
+ * envelope), because a redaction that silently does nothing is worse than no redaction at all.
19
+ */
20
+
21
+ /** Each rule: a class name and the pattern that finds it. Order matters only for the private-key block. */
22
+ const RULES = Object.freeze([
23
+ { cls: 'private-key', pattern: /-----BEGIN [A-Z0-9 ]*PRIVATE KEY-----[\s\S]*?-----END [A-Z0-9 ]*PRIVATE KEY-----/g },
24
+ { cls: 'api-key', pattern: /\bsk-[A-Za-z0-9_-]{16,}\b/g },
25
+ { cls: 'api-key', pattern: /\bnpm_[A-Za-z0-9]{20,}\b/g },
26
+ { cls: 'api-key', pattern: /\bgithub_pat_[A-Za-z0-9_]{20,}\b/g },
27
+ { cls: 'api-key', pattern: /\bgh[pousr]_[A-Za-z0-9]{20,}\b/g },
28
+ { cls: 'api-key', pattern: /\bxox[baprs]-[A-Za-z0-9-]{10,}\b/g },
29
+ { cls: 'aws-key', pattern: /\bAKIA[0-9A-Z]{16}\b/g },
30
+ ]);
31
+
32
+ /**
33
+ * @param {string} text
34
+ * @returns {{text: string, replaced: Array<{cls: string, count: number}>}}
35
+ */
36
+ export function redactText(text) {
37
+ if (typeof text !== 'string' || text === '') return { text: typeof text === 'string' ? text : '', replaced: [] };
38
+ let out = text;
39
+ const counts = new Map();
40
+ for (const rule of RULES) {
41
+ let hits = 0;
42
+ out = out.replace(rule.pattern, () => { hits += 1; return '[redacted:' + rule.cls + ']'; });
43
+ if (hits > 0) counts.set(rule.cls, (counts.get(rule.cls) ?? 0) + hits);
44
+ }
45
+ return { text: out, replaced: [...counts.entries()].map(([cls, count]) => ({ cls, count })) };
46
+ }
47
+
48
+ /** The envelope fields that carry command output and are therefore filtered. */
49
+ export const REDACTABLE_FIELDS = Object.freeze(['stdout_head', 'stdout_tail', 'stderr_head', 'stderr_tail']);
50
+
51
+ /**
52
+ * @param {object} envelope
53
+ * @param {{enabled?: boolean}} [options]
54
+ * @returns {{envelope: object, redacted: Array<{field: string, cls: string, count: number}>}}
55
+ */
56
+ export function redactEnvelope(envelope, { enabled = true } = {}) {
57
+ if (envelope === null || typeof envelope !== 'object') return { envelope, redacted: [] };
58
+ if (enabled !== true) return { envelope: { ...envelope, unredacted: true }, redacted: [] };
59
+ const next = { ...envelope };
60
+ const redacted = [];
61
+ for (const field of REDACTABLE_FIELDS) {
62
+ const value = next[field];
63
+ if (typeof value !== 'string' || value === '') continue;
64
+ const result = redactText(value);
65
+ next[field] = result.text;
66
+ for (const hit of result.replaced) redacted.push({ field, ...hit });
67
+ }
68
+ if (redacted.length > 0) next.redacted = redacted;
69
+ return { envelope: next, redacted };
70
+ }