privateer-agent 0.3.6 → 0.4.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/bin/privateer-daemon.mjs +30 -0
- package/bin/privateer-subagent.mjs +68 -0
- package/bin/privateer-tui +19 -0
- package/extensions/privateer-brand.ts +61 -20
- package/extensions/privateer-gate.ts +290 -3
- package/package.json +4 -1
- package/src/auth/privateer.ts +45 -6
- package/src/channels/bridge.ts +293 -0
- package/src/channels/discord.ts +210 -0
- package/src/channels/run.ts +383 -0
- package/src/channels/slack.ts +176 -0
- package/src/channels/status.ts +54 -0
- package/src/channels/telegram.ts +139 -0
- package/src/channels/types.ts +36 -0
- package/src/channels/whatsapp.ts +178 -0
- package/src/cli/chat.ts +389 -30
- package/src/cli/daemonCli.ts +67 -0
- package/src/crypto/accountTrust.ts +113 -0
- package/src/crypto/accountVerify.ts +138 -0
- package/src/crypto/terminalKey.ts +95 -0
- package/src/crypto/terminalUnseal.ts +62 -0
- package/src/daemon/index.ts +511 -46
- package/src/daemon/service.ts +232 -0
- package/src/ext/permissionGate.ts +38 -0
- package/src/permissions/classify.ts +49 -5
- package/src/remote/channelsControl.ts +192 -0
- package/src/remote/controlAuth.ts +67 -0
- package/src/remote/extensionsControl.ts +140 -0
- package/src/remote/liveTaskSession.ts +218 -0
- package/src/remote/relayClient.ts +512 -1
- package/src/remote/remoteBridge.ts +172 -0
- package/src/remote/routinesControl.ts +216 -0
- package/src/remote/skillsControl.ts +205 -0
- package/src/remote/subagentChannel.ts +261 -0
- package/src/remote/subagentRelay.ts +126 -0
- package/src/remote/workflowsControl.ts +132 -0
- package/src/routines/store.ts +5 -1
- package/src/workflows/expr.ts +4 -0
- package/src/workflows/runner.ts +8 -0
- package/src/workflows/schema.ts +5 -0
- package/src/workflows/store.ts +108 -0
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pinned account signing key — TERMINAL side (Phase 4 TOFU, mirror of the app's
|
|
3
|
+
* terminalTrustService).
|
|
4
|
+
*
|
|
5
|
+
* At link time the app hands this terminal the account's Ed25519 signing public key
|
|
6
|
+
* (bound into the device-code grant, delivered in the /auth/device/token response). We
|
|
7
|
+
* pin it here. Every app channel-save is then verified against this key
|
|
8
|
+
* (accountVerify.ts): only the account master-key holder can sign, so a hostile relay
|
|
9
|
+
* can neither forge a channel config nor inject an admin.
|
|
10
|
+
*
|
|
11
|
+
* The pinned value is a PUBLIC key, but its INTEGRITY is the security property — a
|
|
12
|
+
* local attacker who could swap it would defeat verification — so it's written 0600
|
|
13
|
+
* beside the other machine trust roots (terminal-key.json, config.json). A server
|
|
14
|
+
* malicious at the single link moment could substitute it (the accepted TOFU limit,
|
|
15
|
+
* symmetric with the app-side pin); a server that turns malicious later cannot.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { readFileSync, writeFileSync, chmodSync, rmSync } from "node:fs";
|
|
19
|
+
import { join } from "node:path";
|
|
20
|
+
import { globalDir } from "../config/paths.ts";
|
|
21
|
+
|
|
22
|
+
interface AccountTrustFile {
|
|
23
|
+
v: 1;
|
|
24
|
+
accountSignPub: string; // base64 Ed25519 public key
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function trustPath(): string {
|
|
28
|
+
return join(globalDir(), "account-trust.json");
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Pin the account signing public key (idempotent). A blank/absent value is ignored
|
|
32
|
+
* (an older app or a locked vault simply doesn't establish the pin). */
|
|
33
|
+
export function pinAccountSignKey(pub: string | undefined | null): void {
|
|
34
|
+
const value = (pub ?? "").trim();
|
|
35
|
+
if (!value) return;
|
|
36
|
+
try {
|
|
37
|
+
const file: AccountTrustFile = { v: 1, accountSignPub: value };
|
|
38
|
+
writeFileSync(trustPath(), JSON.stringify(file), { mode: 0o600 });
|
|
39
|
+
try { chmodSync(trustPath(), 0o600); } catch { /* non-POSIX FS */ }
|
|
40
|
+
} catch {
|
|
41
|
+
/* best effort — a missing pin just means channel-saves fail-closed until re-link */
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** The pinned account signing public key (base64), or undefined if none is pinned. */
|
|
46
|
+
export function loadAccountSignKey(): string | undefined {
|
|
47
|
+
try {
|
|
48
|
+
const parsed = JSON.parse(readFileSync(trustPath(), "utf8")) as AccountTrustFile;
|
|
49
|
+
return parsed?.v === 1 && typeof parsed.accountSignPub === "string" ? parsed.accountSignPub : undefined;
|
|
50
|
+
} catch {
|
|
51
|
+
return undefined;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Drop the pin — called when local credentials are cleared (logout / session revoke),
|
|
56
|
+
* since it belongs to the signed-in account.
|
|
57
|
+
*
|
|
58
|
+
* We deliberately do NOT clear the anti-replay watermark (control-sig.json) here.
|
|
59
|
+
* Resetting it to 0 on logout would let a hostile relay replay a previously-captured,
|
|
60
|
+
* validly-signed channel-save after a re-link (its ts > 0 ≥ 0), rolling config back to
|
|
61
|
+
* an earlier account-authored state — e.g. re-adding a removed admin (M1). The
|
|
62
|
+
* watermark is safe to persist across a re-link: it's monotonic wall-clock ms, and a
|
|
63
|
+
* DIFFERENT account that links later gets a different signing key, so its saves are
|
|
64
|
+
* gated by signature (not by ts) and its own ts values only ever move forward. */
|
|
65
|
+
export function clearAccountSignKey(): void {
|
|
66
|
+
try { rmSync(trustPath(), { force: true }); } catch { /* nothing to remove */ }
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// ── Anti-replay watermark for signed control frames (per terminal) ───────────────
|
|
70
|
+
// The highest `ts` we've applied, keyed by termId. A signed control frame (channel
|
|
71
|
+
// save, routine save/run, extension add, skill create, …) whose ts is BELOW the
|
|
72
|
+
// watermark for its terminal is a replay/rollback of an older signed envelope and is
|
|
73
|
+
// refused; at-or-above is accepted (an idempotent replay of the latest is harmless).
|
|
74
|
+
//
|
|
75
|
+
// Keyed by termId — NOT global — so the always-on daemon (stable routines-… id) and
|
|
76
|
+
// each interactive terminal (its own id) don't cross-reject each other's frames when
|
|
77
|
+
// the app drives them near-simultaneously with independent ts streams. Each signed
|
|
78
|
+
// frame binds its termId (see accountVerify.controlMessage), so a per-terminal
|
|
79
|
+
// watermark is the matching granularity. Persisted so it survives a daemon restart;
|
|
80
|
+
// deliberately NOT cleared on logout (see clearAccountSignKey — M1).
|
|
81
|
+
function tsPath(): string {
|
|
82
|
+
return join(globalDir(), "control-sig.json");
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
interface ControlSigFile {
|
|
86
|
+
v: 1;
|
|
87
|
+
byTerm: Record<string, number>;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function loadControlSig(): ControlSigFile {
|
|
91
|
+
try {
|
|
92
|
+
const parsed = JSON.parse(readFileSync(tsPath(), "utf8")) as ControlSigFile;
|
|
93
|
+
if (parsed?.v === 1 && parsed.byTerm && typeof parsed.byTerm === "object") return parsed;
|
|
94
|
+
} catch {
|
|
95
|
+
/* missing/malformed → fresh */
|
|
96
|
+
}
|
|
97
|
+
return { v: 1, byTerm: {} };
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export function loadLastControlTs(termId: string): number {
|
|
101
|
+
const ts = loadControlSig().byTerm[termId];
|
|
102
|
+
return typeof ts === "number" ? ts : 0;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export function saveLastControlTs(termId: string, ts: number): void {
|
|
106
|
+
try {
|
|
107
|
+
const file = loadControlSig();
|
|
108
|
+
file.byTerm[termId] = Math.max(file.byTerm[termId] ?? 0, ts);
|
|
109
|
+
writeFileSync(tsPath(), JSON.stringify(file), { mode: 0o600 });
|
|
110
|
+
} catch {
|
|
111
|
+
/* best effort */
|
|
112
|
+
}
|
|
113
|
+
}
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Account signature verification — TERMINAL side.
|
|
3
|
+
*
|
|
4
|
+
* Phase 4 closes the authenticity gap in app→terminal channel config (review finding
|
|
5
|
+
* F7/F8): a sealed box gives confidentiality but NOT sender authenticity, so a hostile
|
|
6
|
+
* relay/server — which knows this terminal's public key — could forge a channel-save
|
|
7
|
+
* (attacker's token + injected admins). To stop that, the app SIGNS every channel-save
|
|
8
|
+
* with an Ed25519 key derived from the account master key, and the terminal verifies
|
|
9
|
+
* that signature against the account's signing public key it PINNED at link time
|
|
10
|
+
* (accountTrust.ts). Only the master-key holder can produce a valid signature, so the
|
|
11
|
+
* server can neither forge config nor alter the admin list undetected.
|
|
12
|
+
*
|
|
13
|
+
* The canonical message construction MUST stay byte-for-byte in sync with the signer:
|
|
14
|
+
* treeview/client/services/accountSign.ts
|
|
15
|
+
* Domain prefix "privateer-channel-cfg-v1" + canonical JSON (recursively key-sorted).
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { ed25519 } from "@noble/curves/ed25519";
|
|
19
|
+
|
|
20
|
+
const enc = new TextEncoder();
|
|
21
|
+
const DOMAIN = "privateer-channel-cfg-v1";
|
|
22
|
+
|
|
23
|
+
// Deterministic JSON: object keys sorted recursively, arrays kept in order. MUST match
|
|
24
|
+
// treeview/client/services/accountSign.ts canonicalize() exactly.
|
|
25
|
+
function canonicalize(value: unknown): string {
|
|
26
|
+
if (value === null || typeof value !== "object") return JSON.stringify(value);
|
|
27
|
+
if (Array.isArray(value)) return "[" + value.map(canonicalize).join(",") + "]";
|
|
28
|
+
const obj = value as Record<string, unknown>;
|
|
29
|
+
const keys = Object.keys(obj).sort();
|
|
30
|
+
return "{" + keys.map((k) => JSON.stringify(k) + ":" + canonicalize(obj[k])).join(",") + "}";
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// The signed envelope. `termId` binds the intended recipient terminal (so a signature
|
|
34
|
+
// for terminal X can't be replayed against Y — verification uses OUR termId). `ts`
|
|
35
|
+
// binds freshness (the daemon rejects a ts it has already applied → no replay/rollback).
|
|
36
|
+
export interface ChannelSaveEnvelope {
|
|
37
|
+
termId: string;
|
|
38
|
+
ts: number;
|
|
39
|
+
draft: Record<string, unknown>;
|
|
40
|
+
sealedSecrets?: string;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function channelSaveMessage(env: ChannelSaveEnvelope): Uint8Array {
|
|
44
|
+
return enc.encode(
|
|
45
|
+
DOMAIN +
|
|
46
|
+
canonicalize({
|
|
47
|
+
termId: env.termId,
|
|
48
|
+
ts: env.ts,
|
|
49
|
+
draft: env.draft,
|
|
50
|
+
sealedSecrets: env.sealedSecrets ?? null,
|
|
51
|
+
}),
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Verify an Ed25519 signature (base64) over the canonical envelope against the pinned
|
|
56
|
+
* account signing public key (base64). Returns false on ANY malformation — the caller
|
|
57
|
+
* fail-closes (rejects the save) on false. */
|
|
58
|
+
export function verifyChannelSave(accountSignPubB64: string, env: ChannelSaveEnvelope, sigB64: string): boolean {
|
|
59
|
+
try {
|
|
60
|
+
const pub = new Uint8Array(Buffer.from(accountSignPubB64, "base64"));
|
|
61
|
+
const sig = new Uint8Array(Buffer.from(sigB64, "base64"));
|
|
62
|
+
if (pub.length !== 32) return false;
|
|
63
|
+
return ed25519.verify(sig, channelSaveMessage(env), pub);
|
|
64
|
+
} catch {
|
|
65
|
+
return false;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// The outbox recipient key (X25519) is fetched live from the UNTRUSTED server, so a
|
|
70
|
+
// malicious server could otherwise substitute a key it controls and read every sealed
|
|
71
|
+
// result (the terminal holds no master key and can't derive the real one itself). To
|
|
72
|
+
// stop that, the app signs the published outbox public key with the same account
|
|
73
|
+
// Ed25519 key the terminal already pinned at link, and the terminal verifies that
|
|
74
|
+
// signature here before sealing to the key. A server that turns malicious AFTER link
|
|
75
|
+
// can no longer swap the key — it can't forge this signature. Residual: the F1
|
|
76
|
+
// link-moment window, identical to the channel-config path.
|
|
77
|
+
//
|
|
78
|
+
// Message construction MUST stay byte-for-byte in sync with the signer:
|
|
79
|
+
// treeview/client/services/accountSign.ts signOutboxKey()
|
|
80
|
+
const OUTBOX_KEY_DOMAIN = "privateer-outbox-key-v1";
|
|
81
|
+
|
|
82
|
+
/** Verify the account's Ed25519 signature (base64) over the base64 outbox public key
|
|
83
|
+
* against the pinned account signing public key (base64). Returns false on ANY
|
|
84
|
+
* malformation — the caller fail-closes (refuses to seal) on false. */
|
|
85
|
+
export function verifyOutboxKey(accountSignPubB64: string, outboxPubB64: string, sigB64: string): boolean {
|
|
86
|
+
try {
|
|
87
|
+
const pub = new Uint8Array(Buffer.from(accountSignPubB64, "base64"));
|
|
88
|
+
const sig = new Uint8Array(Buffer.from(sigB64, "base64"));
|
|
89
|
+
if (pub.length !== 32) return false;
|
|
90
|
+
return ed25519.verify(sig, enc.encode(OUTBOX_KEY_DOMAIN + outboxPubB64), pub);
|
|
91
|
+
} catch {
|
|
92
|
+
return false;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// ── Generic signed control frames (H2) ──────────────────────────────────────────
|
|
97
|
+
// channels_save is signed (F7/F8), but every OTHER app→terminal mutation
|
|
98
|
+
// (routines_*, extensions_*, skills_*, channels_remove) was sent over the untrusted
|
|
99
|
+
// relay UNSIGNED — so a malicious server could forge them, and several have severe
|
|
100
|
+
// local side effects (a forged routine runs a headless bypass-mode session → RCE; a
|
|
101
|
+
// forged extensions_add installs an npm package → RCE; a forged skills_create injects
|
|
102
|
+
// an auto-invoked system-prompt skill). This closes that: the app signs every mutating
|
|
103
|
+
// control frame with the account key the terminal pinned at link, and the terminal
|
|
104
|
+
// verifies it here (fail-closed) before acting. `termId` binds the recipient (a
|
|
105
|
+
// signature for terminal X won't verify against Y) and `ts` binds freshness (the caller
|
|
106
|
+
// rejects a ts at/below the last it applied → no replay).
|
|
107
|
+
//
|
|
108
|
+
// The canonical message construction MUST stay byte-for-byte in sync with the signer:
|
|
109
|
+
// treeview/client/services/accountSign.ts signControl()
|
|
110
|
+
const CONTROL_DOMAIN = "privateer-control-v1";
|
|
111
|
+
|
|
112
|
+
export interface ControlEnvelope {
|
|
113
|
+
termId: string;
|
|
114
|
+
ts: number;
|
|
115
|
+
action: string; // the frame type, e.g. "routines_save", "extensions_add"
|
|
116
|
+
args: Record<string, unknown>; // the operation's parameters
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export function controlMessage(env: ControlEnvelope): Uint8Array {
|
|
120
|
+
return enc.encode(
|
|
121
|
+
CONTROL_DOMAIN +
|
|
122
|
+
canonicalize({ action: env.action, args: env.args, termId: env.termId, ts: env.ts }),
|
|
123
|
+
);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** Verify an Ed25519 signature (base64) over a control envelope against the pinned
|
|
127
|
+
* account signing public key (base64). Returns false on ANY malformation — the caller
|
|
128
|
+
* fail-closes (refuses the mutation) on false. */
|
|
129
|
+
export function verifyControl(accountSignPubB64: string, env: ControlEnvelope, sigB64: string): boolean {
|
|
130
|
+
try {
|
|
131
|
+
const pub = new Uint8Array(Buffer.from(accountSignPubB64, "base64"));
|
|
132
|
+
const sig = new Uint8Array(Buffer.from(sigB64, "base64"));
|
|
133
|
+
if (pub.length !== 32) return false;
|
|
134
|
+
return ed25519.verify(sig, controlMessage(env), pub);
|
|
135
|
+
} catch {
|
|
136
|
+
return false;
|
|
137
|
+
}
|
|
138
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Terminal identity keypair — the RECIPIENT side of app→terminal sealing.
|
|
3
|
+
*
|
|
4
|
+
* The outbox (crypto/outboxSeal.ts) is one-directional: a terminal seals results TO
|
|
5
|
+
* the account and holds no openable key. This is the mirror: a persistent X25519
|
|
6
|
+
* keypair whose PUBLIC half the app pins at link time (device-code approval) and
|
|
7
|
+
* whose PRIVATE half never leaves this machine — so the app can seal secrets (Phase
|
|
8
|
+
* 3: channel bot tokens) that ONLY this terminal can open, with the server unable to
|
|
9
|
+
* read them even though it forwards the ciphertext.
|
|
10
|
+
*
|
|
11
|
+
* Trust model (TOFU, SSH known_hosts style): the pubkey is delivered to the app once,
|
|
12
|
+
* bound into the device-authorization grant, and pinned on approval. A later key swap
|
|
13
|
+
* over the relay is rejected because it isn't in the pinned set. This does NOT defend
|
|
14
|
+
* against a malicious server at the single link moment — that narrow window is the
|
|
15
|
+
* accepted TOFU limitation (fingerprint verification is the future hardening).
|
|
16
|
+
*
|
|
17
|
+
* Construction matches outboxSeal.ts so Phase 3's open() is the exact inverse of the
|
|
18
|
+
* app's seal(): X25519 → HKDF-SHA256 → AES-256-GCM.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import { readFileSync, writeFileSync, chmodSync } from "node:fs";
|
|
22
|
+
import { join } from "node:path";
|
|
23
|
+
import { x25519 } from "@noble/curves/ed25519";
|
|
24
|
+
import { globalDir } from "../config/paths.ts";
|
|
25
|
+
|
|
26
|
+
interface TerminalKeyFile {
|
|
27
|
+
v: 1;
|
|
28
|
+
publicKey: string; // base64, 32 raw bytes
|
|
29
|
+
secretKey: string; // base64, 32 raw bytes — never leaves this machine
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function keyPath(): string {
|
|
33
|
+
return join(globalDir(), "terminal-key.json");
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// Cache the loaded/created keypair for the process lifetime so we don't re-read the
|
|
37
|
+
// file (or, worse, regenerate) on every device-code request.
|
|
38
|
+
let cached: { publicKey: Uint8Array; secretKey: Uint8Array } | undefined;
|
|
39
|
+
|
|
40
|
+
function b64(bytes: Uint8Array): string {
|
|
41
|
+
return Buffer.from(bytes).toString("base64");
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function fromB64(s: string, label: string): Uint8Array {
|
|
45
|
+
const buf = Buffer.from(s, "base64");
|
|
46
|
+
if (buf.length !== 32) throw new Error(`${label} must be 32 bytes`);
|
|
47
|
+
return new Uint8Array(buf);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Load the persisted keypair, or mint + persist a fresh one on first use. The file is
|
|
51
|
+
// written 0600 (owner-only) — this machine's private key protects every future sealed
|
|
52
|
+
// message to this terminal, so it's as sensitive as the config.json tokens beside it.
|
|
53
|
+
function loadOrCreate(): { publicKey: Uint8Array; secretKey: Uint8Array } {
|
|
54
|
+
if (cached) return cached;
|
|
55
|
+
try {
|
|
56
|
+
const parsed = JSON.parse(readFileSync(keyPath(), "utf8")) as TerminalKeyFile;
|
|
57
|
+
if (parsed?.v === 1 && parsed.publicKey && parsed.secretKey) {
|
|
58
|
+
cached = {
|
|
59
|
+
publicKey: fromB64(parsed.publicKey, "terminal public key"),
|
|
60
|
+
secretKey: fromB64(parsed.secretKey, "terminal secret key"),
|
|
61
|
+
};
|
|
62
|
+
return cached;
|
|
63
|
+
}
|
|
64
|
+
} catch {
|
|
65
|
+
/* missing or malformed → mint a fresh keypair below */
|
|
66
|
+
}
|
|
67
|
+
const secretKey = x25519.utils.randomPrivateKey();
|
|
68
|
+
const publicKey = x25519.getPublicKey(secretKey);
|
|
69
|
+
const file: TerminalKeyFile = { v: 1, publicKey: b64(publicKey), secretKey: b64(secretKey) };
|
|
70
|
+
// Create 0600 from the start — passing `mode` to writeFileSync avoids the TOCTOU
|
|
71
|
+
// window where a fresh file briefly carries umask perms (group/world-readable)
|
|
72
|
+
// before a follow-up chmod. `mode` only applies on CREATE, so also chmod to fix an
|
|
73
|
+
// OVERWRITTEN pre-existing (malformed) file, whose perms writeFileSync leaves as-is.
|
|
74
|
+
writeFileSync(keyPath(), JSON.stringify(file), { mode: 0o600 });
|
|
75
|
+
try {
|
|
76
|
+
chmodSync(keyPath(), 0o600);
|
|
77
|
+
} catch {
|
|
78
|
+
/* best effort — e.g. non-POSIX FS */
|
|
79
|
+
}
|
|
80
|
+
cached = { publicKey, secretKey };
|
|
81
|
+
return cached;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** This terminal's public key, base64 (32 raw bytes). Sent in the device-code grant
|
|
85
|
+
* so the app can pin it; safe to expose (it's public). Mints the keypair on first
|
|
86
|
+
* call. */
|
|
87
|
+
export function terminalPublicKeyBase64(): string {
|
|
88
|
+
return b64(loadOrCreate().publicKey);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** This terminal's private key (raw 32 bytes) — for Phase 3's unseal(). Never send,
|
|
92
|
+
* log, or persist anywhere but the 0600 key file. */
|
|
93
|
+
export function terminalSecretKey(): Uint8Array {
|
|
94
|
+
return loadOrCreate().secretKey;
|
|
95
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* App→terminal sealed-box crypto — RECIPIENT (terminal) side.
|
|
3
|
+
*
|
|
4
|
+
* The inverse of the outbox: here the APP is the sender and this terminal is the
|
|
5
|
+
* recipient. The app seals a secret (a channel bot token) to this terminal's pinned
|
|
6
|
+
* public key (see terminalKey.ts); only this terminal — holder of the matching
|
|
7
|
+
* private key — can open it. The server forwards the ciphertext over the relay and
|
|
8
|
+
* cannot read it, which is the whole point of Phase 3.
|
|
9
|
+
*
|
|
10
|
+
* Construction MUST stay byte-for-byte in sync with the sender:
|
|
11
|
+
* treeview/client/services/terminalSeal.ts
|
|
12
|
+
* X25519 → HKDF-SHA256 → AES-256-GCM. Wire: epk(32) ‖ iv(12) ‖ ct‖tag, base64.
|
|
13
|
+
* salt = epk ‖ recipientPub ; HKDF info = "privateer-channel-seal-v1".
|
|
14
|
+
*
|
|
15
|
+
* Domain separation: the "channel-seal" HKDF label is DISTINCT from the outbox's
|
|
16
|
+
* "privateer-outbox-seal-v1", so a blob from one protocol can never be opened as the
|
|
17
|
+
* other even though both are X25519 sealed boxes to the same curve.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { x25519 } from "@noble/curves/ed25519";
|
|
21
|
+
import { hkdf } from "@noble/hashes/hkdf";
|
|
22
|
+
import { sha256 } from "@noble/hashes/sha256";
|
|
23
|
+
import { gcm } from "@noble/ciphers/aes.js";
|
|
24
|
+
import { terminalSecretKey } from "./terminalKey.ts";
|
|
25
|
+
|
|
26
|
+
const enc = new TextEncoder();
|
|
27
|
+
const dec = new TextDecoder();
|
|
28
|
+
|
|
29
|
+
// MUST match treeview/client/services/terminalSeal.ts.
|
|
30
|
+
const KDF_SEAL = enc.encode("privateer-channel-seal-v1");
|
|
31
|
+
|
|
32
|
+
function concat(a: Uint8Array, b: Uint8Array): Uint8Array {
|
|
33
|
+
const out = new Uint8Array(a.length + b.length);
|
|
34
|
+
out.set(a, 0);
|
|
35
|
+
out.set(b, a.length);
|
|
36
|
+
return out;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Open a base64 wire string the app sealed to THIS terminal's public key. Returns the
|
|
41
|
+
* plaintext bytes; throws on tamper, a wrong recipient, or a malformed blob (the GCM
|
|
42
|
+
* tag check is the integrity guarantee). Uses this terminal's private key — which
|
|
43
|
+
* never leaves the 0600 key file.
|
|
44
|
+
*/
|
|
45
|
+
export function openFromApp(wireB64: string): Uint8Array {
|
|
46
|
+
const sk = terminalSecretKey();
|
|
47
|
+
const recipientPub = x25519.getPublicKey(sk);
|
|
48
|
+
const wire = new Uint8Array(Buffer.from(wireB64, "base64"));
|
|
49
|
+
if (wire.length < 32 + 12 + 16) throw new Error("sealed blob too short");
|
|
50
|
+
const epk = wire.subarray(0, 32);
|
|
51
|
+
const iv = wire.subarray(32, 44);
|
|
52
|
+
const ct = wire.subarray(44);
|
|
53
|
+
const shared = x25519.getSharedSecret(sk, epk);
|
|
54
|
+
const salt = concat(epk, recipientPub); // binds ephemeral + this recipient
|
|
55
|
+
const key = hkdf(sha256, shared, salt, KDF_SEAL, 32);
|
|
56
|
+
return gcm(key, iv).decrypt(ct); // throws if the tag doesn't verify
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Open + JSON-parse. Throws on tamper/wrong-key/invalid JSON. */
|
|
60
|
+
export function openJsonFromApp<T = unknown>(wireB64: string): T {
|
|
61
|
+
return JSON.parse(dec.decode(openFromApp(wireB64))) as T;
|
|
62
|
+
}
|