viveworker 0.7.0 → 0.8.1

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.
Files changed (54) hide show
  1. package/.agents/plugins/marketplace.json +20 -0
  2. package/README.md +115 -4
  3. package/package.json +13 -3
  4. package/plugins/viveworker-control-plane/.codex-plugin/plugin.json +49 -0
  5. package/plugins/viveworker-control-plane/.mcp.json +11 -0
  6. package/plugins/viveworker-control-plane/assets/viveworker-logo-v2.png +0 -0
  7. package/plugins/viveworker-control-plane/assets/viveworker-logo-v2.svg +39 -0
  8. package/plugins/viveworker-control-plane/skills/viveworker-control-plane/SKILL.md +83 -0
  9. package/scripts/lib/markdown-render.mjs +128 -1
  10. package/scripts/lib/remote-pairing/README.md +164 -0
  11. package/scripts/lib/remote-pairing/_browser-bundle-entry.mjs +86 -0
  12. package/scripts/lib/remote-pairing/audit.mjs +122 -0
  13. package/scripts/lib/remote-pairing/bridge-relay-client.mjs +737 -0
  14. package/scripts/lib/remote-pairing/control.mjs +156 -0
  15. package/scripts/lib/remote-pairing/envelope.mjs +224 -0
  16. package/scripts/lib/remote-pairing/http-dispatch.mjs +530 -0
  17. package/scripts/lib/remote-pairing/keys-core.mjs +110 -0
  18. package/scripts/lib/remote-pairing/keys.mjs +181 -0
  19. package/scripts/lib/remote-pairing/noise.mjs +436 -0
  20. package/scripts/lib/remote-pairing/orchestrator.mjs +356 -0
  21. package/scripts/lib/remote-pairing/pairings.mjs +446 -0
  22. package/scripts/lib/remote-pairing/rpc.mjs +381 -0
  23. package/scripts/mcp-server.mjs +891 -0
  24. package/scripts/moltbook-scout-auto.sh +16 -8
  25. package/scripts/share-cli.mjs +14 -130
  26. package/scripts/stats-cli.mjs +683 -0
  27. package/scripts/viveworker-bridge.mjs +1676 -127
  28. package/scripts/viveworker.mjs +289 -7
  29. package/skills/viveworker-control-plane/SKILL.md +104 -0
  30. package/skills/viveworker-control-plane/agents/openai.yaml +12 -0
  31. package/templates/CLAUDE.viveworker.md +67 -0
  32. package/web/app.css +683 -9
  33. package/web/app.js +1780 -187
  34. package/web/build-id.js +1 -0
  35. package/web/i18n.js +187 -9
  36. package/web/icons/apple-touch-icon.png +0 -0
  37. package/web/icons/viveworker-icon-192.png +0 -0
  38. package/web/icons/viveworker-icon-512.png +0 -0
  39. package/web/icons/viveworker-v-pulse.svg +16 -1
  40. package/web/index.html +32 -2
  41. package/web/remote-pairing/api-router.js +873 -0
  42. package/web/remote-pairing/keys.js +237 -0
  43. package/web/remote-pairing/pairing-state.js +313 -0
  44. package/web/remote-pairing/rpc-client.js +765 -0
  45. package/web/remote-pairing/transport.js +804 -0
  46. package/web/remote-pairing/wake.js +149 -0
  47. package/web/remote-pairing-test.html +400 -0
  48. package/web/remote-pairing.bundle.js +3 -0
  49. package/web/remote-pairing.bundle.js.LEGAL.txt +15 -0
  50. package/web/remote-pairing.bundle.js.map +7 -0
  51. package/web/sw.js +190 -20
  52. package/web/icons/viveworker-beacon-v.svg +0 -19
  53. package/web/icons/viveworker-icon-1024.png +0 -0
  54. package/web/icons/viveworker-v-check.svg +0 -19
@@ -0,0 +1,164 @@
1
+ # `scripts/lib/remote-pairing/` — viveworker remote pairing crypto
2
+
3
+ End-to-end encryption layer for connecting phone PWA ↔ PC bridge over an
4
+ **untrusted relay** (Cloudflare Worker + Durable Object). Status: **Phase 0**
5
+ — crypto primitives + handshake + identity key persistence verified by
6
+ unit tests and a two-process E2E demo. Transport (WSS), envelope (replay
7
+ buffer / sequence numbers), and PWA wiring are tracked separately.
8
+
9
+ ## Why this exists
10
+
11
+ viveworker today pairs a phone PWA and a Mac bridge over the local LAN
12
+ (`https://<lan-ip>:8810` with mkcert). For users away from the LAN we
13
+ want the same UX without:
14
+
15
+ - shipping a phone-side app (PWA constraint),
16
+ - terminating TLS at a third party (no Cloudflare Tunnel),
17
+ - exposing a public HTTPS endpoint to the internet at large.
18
+
19
+ The chosen architecture is a Cloudflare Worker + Durable Object that
20
+ brokers WSS connections between phone and PC. This module is the
21
+ **E2EE layer that runs on top of that relay**. The relay carries
22
+ ciphertext and routing metadata; it never sees decrypted application
23
+ data and never holds long-term keys.
24
+
25
+ ## Protocol choice: `Noise_IK_25519_ChaChaPoly_SHA256`
26
+
27
+ Why IK specifically:
28
+
29
+ - The phone (initiator) knows the PC bridge's static public key from the
30
+ pairing flow that happened over LAN. With that prior knowledge IK
31
+ gives **mutual authentication in 1 round-trip** (msg 1: phone → PC,
32
+ msg 2: PC → phone, then transport).
33
+ - Both sides' static keys are bound into the handshake hash, so a MitM
34
+ who tampers with either side fails to produce a valid AEAD tag.
35
+ - The chosen DH (`X25519`), AEAD (`ChaCha20-Poly1305`), and hash (`SHA256`)
36
+ all map cleanly to Web Crypto / libsodium.js / `@noble/*` so the same
37
+ protocol is implementable in the PWA without a polyfill audit.
38
+
39
+ What we **explicitly didn't pick**:
40
+
41
+ - **Static-static ECDH with a long-term symmetric key**: would lose
42
+ forward secrecy. A relay snapshot + future identity-key compromise
43
+ would decrypt all past sessions.
44
+ - **Signal Double Ratchet**: per-message rotation, but for a request/
45
+ response control plane the cost (more state, more code, more
46
+ failure modes on resume) doesn't earn its keep.
47
+ - **Noise XX**: would work too, but adds a round trip for static-key
48
+ exchange that IK avoids.
49
+
50
+ ## Layered key model
51
+
52
+ Three keys, kept strictly separated. Mixing them is a confused-deputy
53
+ hazard (e.g., a wallet-signed device-pairing message becoming a wallet
54
+ authorization).
55
+
56
+ | Layer | Algo | Lifetime | Where | Used for |
57
+ | -------- | ------------------- | ------------------------ | ----------------- | ----------------------------------------- |
58
+ | Wallet | `secp256k1` | Long-term | hazBase / wallet | EVM signing, USDC x402 payments |
59
+ | Identity | `X25519` | Long-term, per device | This module | Noise IK static `s`, device-pair auth |
60
+ | Session | `X25519` ephemeral | Per WS connection | Derived in Noise | AEAD `k`/`n`, **per-session forward sec** |
61
+
62
+ The session key never touches disk. Each new WSS connection runs a fresh
63
+ IK handshake → fresh ephemeral DH → fresh `CipherState`. A relay
64
+ compromise plus future identity-key leak still cannot decrypt past
65
+ recordings.
66
+
67
+ ## Files
68
+
69
+ - `noise.mjs` — Noise IK state machine. `HandshakeState`, `CipherState`,
70
+ `SymmetricState`, `NoiseSession`. Pure Uint8Array I/O so the same
71
+ module runs in Node and browser bundlers (no `Buffer` leakage).
72
+ - `keys.mjs` — Identity key generation, hex encoding, persistence to
73
+ `~/.viveworker/remote-pairing.env` (file mode `0o600`, dir `0o700`).
74
+ Includes `fingerprintIdentity()` for human-readable verification
75
+ strings (`XXXX-XXXX-XXXX`, Crockford-ish alphabet).
76
+
77
+ ## Wire layout (Phase 0)
78
+
79
+ This module produces and consumes **Noise transport messages only**.
80
+ Envelope-level fields (sequence numbers, message IDs, replay buffer
81
+ indexing) are deliberately not part of this layer — they're added by
82
+ the Phase 1 envelope module so the relay can route without seeing
83
+ ciphertext. End-state target:
84
+
85
+ ```
86
+ WSS frame body (binary)
87
+ └── outer envelope ← visible to relay; routing-only
88
+ ├── seq: u32 ← replay buffer ordering
89
+ ├── mid: 16 bytes (UUID) ← deduplication
90
+ ├── type: data | ack | ping | resume
91
+ └── payload: bytes ← Noise transport message (this module)
92
+ └── ciphertext + 16-byte Poly1305 tag
93
+ ```
94
+
95
+ Message sizes today:
96
+
97
+ - **Handshake msg 1** (initiator → responder, `e, es, s, ss + payload`):
98
+ `32 + 32 + 16 + payload_len + 16` bytes (e pub, encrypted s pub, AEAD tag,
99
+ encrypted payload, AEAD tag).
100
+ - **Handshake msg 2** (responder → initiator, `e, ee, se + payload`):
101
+ `32 + payload_len + 16` bytes.
102
+ - **Transport message**: `payload_len + 16` bytes.
103
+
104
+ ## Channel binding
105
+
106
+ After handshake both sides agree on a 32-byte hash of the handshake
107
+ transcript. `NoiseSession.getChannelBinding()` returns it. Higher
108
+ layers can pin sensitive operations to this binding — e.g., a
109
+ **WebAuthn challenge that the PWA derives from the channel binding**,
110
+ so a passkey assertion is bound to the specific Noise session, not
111
+ just to "any browser session at this origin."
112
+
113
+ This is how we'll wire Phase 5's "passkey-confirm before USDC payment"
114
+ flow without inventing a separate challenge nonce.
115
+
116
+ ## Testing
117
+
118
+ ```bash
119
+ # Unit tests — protocol semantics (15 tests)
120
+ node --test scripts/test/remote-pairing-noise.test.mjs
121
+
122
+ # End-to-end demo — two real processes over stdio pipes (Phase 0 DoD)
123
+ node scripts/test/remote-pairing-demo.mjs
124
+ ```
125
+
126
+ The demo prints a transcript of both sides' wire frames in hex; eyeball
127
+ that no plaintext (e.g. `"approve task #42"`) appears anywhere outside
128
+ the decrypted payloads.
129
+
130
+ ## Phase 0 → Phase 1 hand-off
131
+
132
+ What this module guarantees today:
133
+
134
+ - ✅ Handshake completes with 1 round-trip given a pre-shared responder static key.
135
+ - ✅ Mutual authentication (wrong responder static key fails handshake).
136
+ - ✅ Forward secrecy per session (ephemeral DH keys, dropped on session end).
137
+ - ✅ AEAD-bound associated data (envelope tampering breaks decryption).
138
+ - ✅ Channel binding for higher-layer auth pinning.
139
+ - ✅ Identity keys persist to disk with strict perms; round-trip verified.
140
+
141
+ What Phase 1 still needs to add (not this module's job):
142
+
143
+ - 🔜 **Envelope** with `seq` / `mid` / `type` for routing + dedup.
144
+ - 🔜 **Replay buffer** in the Durable Object (5-min TTL by default).
145
+ - 🔜 **Reconnect resume** via `RESUME { lastSeq }` → DO drains buffer.
146
+ - 🔜 **At-least-once delivery** with explicit `ACK { mid }` frames.
147
+ - 🔜 **Hibernatable WS** in the Durable Object (cost-critical from day 1).
148
+ - 🔜 **Ping/pong** every 30–45 s to defeat CF's ~100 s WS idle timeout.
149
+
150
+ What Phase 2 still needs to add (PWA side):
151
+
152
+ - 🔜 PWA bundle of `noise.mjs` (already Uint8Array-clean, should JustWork).
153
+ - 🔜 Identity key persistence in browser (IndexedDB; non-extractable
154
+ CryptoKey if the platform supports it, else raw bytes with a passkey
155
+ unlock).
156
+ - 🔜 `visibilitychange` → reconnect-with-resume flow.
157
+ - 🔜 Web Push wake → re-establish WS pipe.
158
+
159
+ ## References
160
+
161
+ - Noise spec rev 34: <https://noiseprotocol.org/noise.html>
162
+ - IK pattern security: § 7.4 "Pattern security properties"
163
+ - HKDF: RFC 5869, used as `MixKey` per Noise § 4.3
164
+ - ChaCha20-Poly1305 nonce format: 4 zero bytes + 8-byte LE counter (Noise § 5.1)
@@ -0,0 +1,86 @@
1
+ /**
2
+ * _browser-bundle-entry.mjs — Aggregator for the browser bundle.
3
+ *
4
+ * The PWA can't pull npm packages directly (no build step on the
5
+ * served-files side). This module is the input to esbuild — it pulls in
6
+ * noise.mjs + envelope.mjs + keys-core.mjs and their @noble/* dependencies
7
+ * so the bundler can produce a single self-contained ESM file
8
+ * (`web/remote-pairing.bundle.js`).
9
+ *
10
+ * To regenerate the bundle:
11
+ *
12
+ * node scripts/build-remote-pairing-bundle.mjs
13
+ *
14
+ * Anything Node-specific (load/save/ensure helpers from keys.mjs, the
15
+ * stdio demo, the wrangler-driven smoke test) must NOT be re-exported
16
+ * here — those would either fail to bundle or pull node_modules into
17
+ * the browser payload.
18
+ */
19
+
20
+ // Crypto / handshake / session
21
+ export {
22
+ PROTOCOL_NAME,
23
+ HASHLEN,
24
+ KEYLEN,
25
+ DHLEN,
26
+ TAGLEN,
27
+ HandshakeState,
28
+ NoiseSession,
29
+ createInitiator,
30
+ createResponder,
31
+ } from "./noise.mjs";
32
+
33
+ // Wire envelope (frame types, encoders, decoder, helpers)
34
+ export {
35
+ FRAME_DATA,
36
+ FRAME_ACK,
37
+ FRAME_PING,
38
+ FRAME_PONG,
39
+ FRAME_RESUME_REQ,
40
+ FRAME_RESUME_OK,
41
+ FRAME_RESUME_FAIL,
42
+ RESUME_FAIL_BUFFER_EXPIRED,
43
+ RESUME_FAIL_UNKNOWN_PAIRING,
44
+ RESUME_FAIL_HIBERNATED,
45
+ MID_BYTES,
46
+ encodeData,
47
+ encodeAck,
48
+ encodePing,
49
+ encodePong,
50
+ encodeResumeReq,
51
+ encodeResumeOk,
52
+ encodeResumeFail,
53
+ decode,
54
+ generateMid,
55
+ midToHex,
56
+ frameTypeName,
57
+ } from "./envelope.mjs";
58
+
59
+ // Pure key helpers (encoding, derivation, fingerprint). The browser side
60
+ // layers IndexedDB-backed persistence over these in web/remote-pairing/keys.js.
61
+ export {
62
+ IDENTITY_KEY_BYTES,
63
+ bytesToHex,
64
+ hexToBytes,
65
+ generateIdentityKeypair,
66
+ publicFromPrivate,
67
+ fingerprintIdentity,
68
+ } from "./keys-core.mjs";
69
+
70
+ // Application-level RPC framing carried inside the Noise channel. Phone-side
71
+ // rpc-client.js imports these to encode requests / decode responses + events.
72
+ export {
73
+ RPC,
74
+ MAX_RPC_ID_LEN,
75
+ MAX_HEADERS,
76
+ MAX_HEADER_LEN,
77
+ MAX_METHOD_LEN,
78
+ MAX_PATH_LEN,
79
+ MAX_BODY_BYTES,
80
+ MAX_TOPIC_LEN,
81
+ encodeRequest,
82
+ encodeResponse,
83
+ encodeCancel,
84
+ encodeEvent,
85
+ decode as decodeRpc,
86
+ } from "./rpc.mjs";
@@ -0,0 +1,122 @@
1
+ /**
2
+ * audit.mjs — small local audit log for remote-pairing operations.
3
+ *
4
+ * This intentionally stores only human-operational metadata. Relay tokens,
5
+ * request bodies, channel bindings, and raw phone public keys are never
6
+ * written here.
7
+ */
8
+
9
+ import { randomBytes } from "node:crypto";
10
+ import { promises as fs } from "node:fs";
11
+ import os from "node:os";
12
+ import path from "node:path";
13
+
14
+ export const REMOTE_PAIRING_AUDIT_FILE = path.join(
15
+ os.homedir(),
16
+ ".viveworker",
17
+ "remote-pairing-audit.jsonl",
18
+ );
19
+
20
+ const MAX_AUDIT_EVENTS = 500;
21
+ const MAX_READ_EVENTS = 100;
22
+ const TYPE_RE = /^[a-z0-9_.:-]{1,64}$/u;
23
+ const OUTCOMES = new Set(["success", "failure", "info"]);
24
+
25
+ export async function appendRemotePairingAuditEvent(event, {
26
+ filePath = REMOTE_PAIRING_AUDIT_FILE,
27
+ } = {}) {
28
+ const record = normalizeAuditEvent(event);
29
+ const dir = path.dirname(filePath);
30
+ await fs.mkdir(dir, { recursive: true, mode: 0o700 });
31
+ await fs.appendFile(filePath, `${JSON.stringify(record)}\n`, { mode: 0o600 });
32
+ await pruneAuditFile(filePath).catch(() => {});
33
+ return record;
34
+ }
35
+
36
+ export async function readRemotePairingAuditEvents({
37
+ limit = 20,
38
+ filePath = REMOTE_PAIRING_AUDIT_FILE,
39
+ } = {}) {
40
+ const safeLimit = Math.max(0, Math.min(Number(limit) || 20, MAX_READ_EVENTS));
41
+ if (safeLimit === 0) return [];
42
+
43
+ let text;
44
+ try {
45
+ text = await fs.readFile(filePath, "utf8");
46
+ } catch (err) {
47
+ if (err.code === "ENOENT") return [];
48
+ throw err;
49
+ }
50
+
51
+ const events = [];
52
+ for (const line of text.split(/\r?\n/u)) {
53
+ if (!line.trim()) continue;
54
+ try {
55
+ events.push(normalizeAuditEvent(JSON.parse(line)));
56
+ } catch {
57
+ // Ignore malformed historical lines. The audit log must never break
58
+ // the settings page.
59
+ }
60
+ }
61
+ return events
62
+ .sort((a, b) => b.atMs - a.atMs)
63
+ .slice(0, safeLimit);
64
+ }
65
+
66
+ function normalizeAuditEvent(event) {
67
+ const atMs = Number.isFinite(event?.atMs) ? Number(event.atMs) : Date.now();
68
+ const type = TYPE_RE.test(String(event?.type || ""))
69
+ ? String(event.type)
70
+ : "unknown";
71
+ const outcome = OUTCOMES.has(String(event?.outcome || ""))
72
+ ? String(event.outcome)
73
+ : "info";
74
+
75
+ const out = {
76
+ id: cleanText(event?.id, 80) || `${atMs}-${randomBytes(4).toString("hex")}`,
77
+ atMs,
78
+ type,
79
+ outcome,
80
+ };
81
+
82
+ copyText(out, event, "label", 120);
83
+ copyText(out, event, "phoneFingerprint", 32);
84
+ copyText(out, event, "deviceId", 120);
85
+ copyText(out, event, "pairingId", 24);
86
+ copyText(out, event, "state", 32);
87
+ copyText(out, event, "previousState", 32);
88
+ copyText(out, event, "route", 32);
89
+ copyText(out, event, "reason", 160);
90
+ copyText(out, event, "relayHost", 120);
91
+
92
+ return out;
93
+ }
94
+
95
+ function copyText(out, event, key, maxLen) {
96
+ const value = cleanText(event?.[key], maxLen);
97
+ if (value) out[key] = value;
98
+ }
99
+
100
+ function cleanText(value, maxLen) {
101
+ if (typeof value !== "string") return "";
102
+ return value
103
+ .replace(/[\r\n\t]+/gu, " ")
104
+ .replace(/\s{2,}/gu, " ")
105
+ .trim()
106
+ .slice(0, maxLen);
107
+ }
108
+
109
+ async function pruneAuditFile(filePath) {
110
+ let text;
111
+ try {
112
+ text = await fs.readFile(filePath, "utf8");
113
+ } catch {
114
+ return;
115
+ }
116
+ const lines = text.split(/\r?\n/u).filter((line) => line.trim());
117
+ if (lines.length <= MAX_AUDIT_EVENTS) return;
118
+ const kept = lines.slice(-MAX_AUDIT_EVENTS).join("\n") + "\n";
119
+ const tmpPath = `${filePath}.tmp-${process.pid}-${Date.now()}-${randomBytes(4).toString("hex")}`;
120
+ await fs.writeFile(tmpPath, kept, { mode: 0o600 });
121
+ await fs.rename(tmpPath, filePath);
122
+ }