@sksoftofficial/ocduet 0.2.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.
package/src/e2ee.js ADDED
@@ -0,0 +1,141 @@
1
+ // ocduet e2ee — application-layer end-to-end encryption for phone <-> daemon.
2
+ // Identity: daemon + each phone hold long-term Ed25519 keypairs. Pairing pins
3
+ // the daemon key on the phone via a spoken/compared word fingerprint.
4
+ // Session: per-connection ephemeral X25519 -> ECDH -> HKDF-SHA256 -> two
5
+ // AES-256-GCM keys (one per direction), counter-bound 96-bit IVs (replay
6
+ // rejected). Frames: {t:"sec", n, ct}.
7
+ import fs from "node:fs";
8
+ import path from "node:path";
9
+ import crypto from "node:crypto";
10
+ import { dataDir } from "./paths.js";
11
+
12
+ const KEYS_PATH = () => path.join(dataDir(), "keys.json");
13
+ const HKDF_SALT = Buffer.from("ocduet-e2ee-v1");
14
+ export const PD_TAG = Buffer.from("P2D\x00"); // phone -> daemon direction tag
15
+ export const DP_TAG = Buffer.from("D2P\x00"); // daemon -> phone direction tag
16
+
17
+ // 256 short common words — fingerprint byte -> word
18
+ const WORDS = ("acid acorn actor aged agile album alert alley almond amber anchor angle ankle apple april arc arch arena armor arrow ash ashen atlas atom aunt awake axis bacon badge bagel baker balloon bamboo banjo barge basil batch beach beacon beam bean bear beech beetle bell belt berry birch bison blade blank blast blaze bloom blossom blue bluff blush boat bobcat bonfire bonsai book booth borrow bottle boulder bounce bowl brave bread breeze brick bridge bright broom brush bubble bucket buffalo bugle bulb bundle bunny burrow cabin cactus camel candle canoe canvas canyon cargo carrot castle cattle cave cedar celery cellar chalk charm cheddar cherry chess chili chimney chipmunk circle citrus clay clerk cliff cloak clock cloud clover cobalt cobra cocoa comet compass concert coral cotton cougar course coyote crab crane crate creek crest cricket crimson cross crown cube cumin curve cyclone dahlia daisy dance dawn deck deer delta denim desert diamond dice diner dingo ditch dock dolphin domain donut dragon drift drum dune eagle earth easel echo eclipse edge eel elbow elder elk elm ember emerald engine evening exact fable falcon flannel feather fedora fennel fern ferry fiber fiddle fig filter finch fire fish fjord flag flamingo flask flint float flour flute foam fog forest fossil fox frame freckle fresh frog frost fudge funnel gadget galaxy garden garlic gauge gecko gem ghost giant ginger glacier glider globe glow gnome goat gold goose gorge granite grape gravel green grotto guitar gulf gully gumbo gutter gymnast hamster harbor harvest hatch hawk hazel heather hedge helmet heron hickory hollow").split(/\s+/);
19
+
20
+ export function wordsFor(buf) {
21
+ const h = crypto.createHash("sha256").update(buf).digest();
22
+ return [0, 1, 2, 3, 4, 5].map((i) => WORDS[h[i]]);
23
+ }
24
+
25
+ // ---------- key store ----------
26
+ let store = null;
27
+
28
+ export function loadKeys() {
29
+ if (store) return store;
30
+ try {
31
+ const raw = JSON.parse(fs.readFileSync(KEYS_PATH(), "utf8"));
32
+ if (raw?.identity?.priv && raw?.identity?.pub) {
33
+ store = {
34
+ priv: crypto.createPrivateKey({ key: Buffer.from(raw.identity.priv, "base64"), format: "der", type: "pkcs8" }),
35
+ pub: Buffer.from(raw.identity.pub, "base64"),
36
+ phones: new Map((raw.phones || []).map((p) => [p.id, p])),
37
+ };
38
+ return store;
39
+ }
40
+ } catch {}
41
+ const { publicKey, privateKey } = crypto.generateKeyPairSync("ed25519");
42
+ const pub = publicKey.export({ format: "der", type: "spki" }).slice(-32); // raw 32-byte ed25519 pub
43
+ const priv = privateKey.export({ format: "der", type: "pkcs8" });
44
+ store = { priv: crypto.createPrivateKey({ key: priv, format: "der", type: "pkcs8" }), pub, phones: new Map() };
45
+ saveKeys();
46
+ return store;
47
+ }
48
+
49
+ export function saveKeys() {
50
+ if (!store) return;
51
+ fs.mkdirSync(dataDir(), { recursive: true });
52
+ const tmp = KEYS_PATH() + `.${process.pid}.tmp`;
53
+ const priv = store.priv.export({ format: "der", type: "pkcs8" });
54
+ fs.writeFileSync(tmp, JSON.stringify({
55
+ identity: { pub: store.pub.toString("base64"), priv: priv.toString("base64") },
56
+ phones: [...store.phones.values()],
57
+ }));
58
+ fs.chmodSync(tmp, 0o600);
59
+ fs.renameSync(tmp, KEYS_PATH());
60
+ }
61
+
62
+ export function daemonPub() { return loadKeys().pub; }
63
+ export function daemonWords() { return wordsFor(loadKeys().pub); }
64
+ export function signDaemon(msgStr) {
65
+ return crypto.sign(null, Buffer.from(msgStr, "utf8"), loadKeys().priv).toString("base64");
66
+ }
67
+ function pubFromRaw(rawBuf, crv) {
68
+ const b64url = Buffer.from(rawBuf).toString("base64url");
69
+ return crypto.createPublicKey({ key: { kty: "OKP", crv, x: b64url }, format: "jwk" });
70
+ }
71
+
72
+ export function verifySig(pubBuf, msgStr, sigB64) {
73
+ try {
74
+ return crypto.verify(null, Buffer.from(msgStr, "utf8"), pubFromRaw(pubBuf, "Ed25519"), Buffer.from(sigB64, "base64"));
75
+ } catch {
76
+ return false;
77
+ }
78
+ }
79
+
80
+ export function addPhone({ id, pub }) {
81
+ const k = loadKeys();
82
+ k.phones.set(id, { id, pub, addedAt: Date.now() });
83
+ saveKeys();
84
+ }
85
+ export function phoneByPub(pubBuf) {
86
+ const k = loadKeys();
87
+ for (const p of k.phones.values()) if (Buffer.compare(Buffer.from(p.pub, "base64"), pubBuf) === 0) return p;
88
+ return null;
89
+ }
90
+
91
+ // ---------- session crypto ----------
92
+ export function ephX25519() {
93
+ const { publicKey, privateKey } = crypto.generateKeyPairSync("x25519");
94
+ const pub = publicKey.export({ format: "der", type: "spki" }).slice(-32);
95
+ return { pub, priv: privateKey };
96
+ }
97
+
98
+ export function ecdh(privKeyObj, remotePubBuf) {
99
+ return crypto.diffieHellman({
100
+ privateKey: privKeyObj,
101
+ publicKey: pubFromRaw(remotePubBuf, "X25519"),
102
+ });
103
+ }
104
+
105
+ export function hsInfo(phonePubB64, phoneEphB64, daemonEphB64) {
106
+ return `ocduet-e2ee-v1|${phonePubB64}|${phoneEphB64}|${daemonEphB64}`;
107
+ }
108
+
109
+ export function deriveKeys(shared, infoStr) {
110
+ const okm = Buffer.from(crypto.hkdfSync("sha256", shared, HKDF_SALT, Buffer.from(infoStr, "utf8"), 64));
111
+ return { pd: okm.subarray(0, 32), dp: okm.subarray(32, 64) }; // pd = phone->daemon, dp = daemon->phone
112
+ }
113
+
114
+ function ivFor(tag, counter) {
115
+ const iv = Buffer.alloc(12);
116
+ tag.copy(iv, 0);
117
+ iv.writeBigUInt64BE(BigInt(counter), 4);
118
+ return iv;
119
+ }
120
+
121
+ export function frameEncrypt(keyBuf, tag, counter, obj) {
122
+ const iv = ivFor(tag, counter);
123
+ const cipher = crypto.createCipheriv("aes-256-gcm", keyBuf, iv, { authTagLength: 16 });
124
+ const ct = Buffer.concat([cipher.update(JSON.stringify(obj), "utf8"), cipher.final(), cipher.getAuthTag()]);
125
+ return ct.toString("base64");
126
+ }
127
+
128
+ export function frameDecrypt(keyBuf, tag, counter, ctB64) {
129
+ const buf = Buffer.from(ctB64, "base64");
130
+ if (buf.length < 16) throw new Error("short frame");
131
+ const iv = ivFor(tag, counter);
132
+ const decipher = crypto.createDecipheriv("aes-256-gcm", keyBuf, iv, { authTagLength: 16 });
133
+ decipher.setAuthTag(buf.subarray(buf.length - 16));
134
+ const pt = Buffer.concat([decipher.update(buf.subarray(0, buf.length - 16)), decipher.final()]);
135
+ return JSON.parse(pt.toString("utf8"));
136
+ }
137
+
138
+ // handshake canonical strings — keep in sync with the phone
139
+ export const hs1Msg = (phonePubB64, ephB64, token, ts) => `ocduet-hs1|${phonePubB64}|${ephB64}|${token}|${ts}`;
140
+ export const hs2Msg = (phonePubB64, phoneEphB64, daemonEphB64) => `ocduet-hs2|${phonePubB64}|${phoneEphB64}|${daemonEphB64}`;
141
+ export const pairMsg = (phoneId, phonePubB64, token) => `ocduet-pair|${phoneId}|${phonePubB64}|${token}`;
package/src/paths.js ADDED
@@ -0,0 +1,137 @@
1
+ import os from "node:os";
2
+ import path from "node:path";
3
+ import fs from "node:fs";
4
+ import crypto from "node:crypto";
5
+
6
+ export function opencodeConfigDir() {
7
+ const override = process.env.OPENCODE_CONFIG_DIR;
8
+ if (override) return override;
9
+ const xdg = process.env.XDG_CONFIG_HOME;
10
+ const base = xdg && path.isAbsolute(xdg) ? xdg : path.join(os.homedir(), ".config");
11
+ return path.join(base, "opencode");
12
+ }
13
+
14
+ export function pluginDir() {
15
+ return path.join(opencodeConfigDir(), "plugin");
16
+ }
17
+
18
+ export function dataDir() {
19
+ const override = process.env.OCDUET_DATA_DIR;
20
+ if (override && path.isAbsolute(override)) return override;
21
+ // opencode profiles can override XDG_DATA_HOME independently of the shell
22
+ // that installed the bridge. All profiles must use the same installation.
23
+ try {
24
+ const installed = JSON.parse(fs.readFileSync(installationPath(), "utf8"));
25
+ if (typeof installed.dataDir === "string" && path.isAbsolute(installed.dataDir)) return installed.dataDir;
26
+ } catch {}
27
+ const xdg = process.env.XDG_DATA_HOME;
28
+ const base = xdg && path.isAbsolute(xdg) ? xdg : path.join(os.homedir(), ".local", "share");
29
+ return path.join(base, "ocduet");
30
+ }
31
+
32
+ export function installationPath() {
33
+ return path.join(pluginDir(), "ocduet-installation.json");
34
+ }
35
+
36
+ export function tokenPath() {
37
+ return path.join(dataDir(), "token");
38
+ }
39
+
40
+ export function daemonStatePath() {
41
+ return path.join(dataDir(), "state.json");
42
+ }
43
+
44
+ export function daemonPidPath() {
45
+ return path.join(dataDir(), "daemon.pid");
46
+ }
47
+
48
+ export function daemonLogPath() {
49
+ return path.join(dataDir(), "daemon.log");
50
+ }
51
+
52
+ export function daemonWebDir() {
53
+ return path.join(dataDir(), "web");
54
+ }
55
+
56
+ // pointer file written by install: {"daemonPath": "/abs/path/to/src/daemon.js"}
57
+ export function daemonPointerPath() {
58
+ return path.join(dataDir(), "daemon.json");
59
+ }
60
+
61
+ export function readDaemonPort() {
62
+ try {
63
+ const s = JSON.parse(fs.readFileSync(daemonStatePath(), "utf8"));
64
+ if (s && typeof s.port === "number") return s.port;
65
+ } catch {}
66
+ return null;
67
+ }
68
+
69
+ // loopback plain-http port for CLI/stub health checks — the main port is https
70
+ // since the dual-server change, so plain fetches against it always fail
71
+ export function readDaemonControlPort() {
72
+ try {
73
+ const s = JSON.parse(fs.readFileSync(daemonStatePath(), "utf8"));
74
+ if (s && typeof s.localPort === "number" && s.localPort > 0) return s.localPort;
75
+ if (s && typeof s.port === "number") return s.port;
76
+ } catch {}
77
+ return null;
78
+ }
79
+
80
+ export function statePath(directory) {
81
+ return path.join(dataDir(), `state-${slug(directory)}.json`);
82
+ }
83
+
84
+ export function slug(directory) {
85
+ const base = path.basename(String(directory || "").replace(/\/+$/, "")) || "project";
86
+ const safe = base.toLowerCase().replace(/[^a-z0-9._-]+/g, "-").replace(/^[-.]+|[-.]+$/g, "") || "project";
87
+ const hash = crypto.createHash("sha256").update(String(directory || "")).digest("hex").slice(0, 8);
88
+ return `${safe}-${hash}`;
89
+ }
90
+
91
+ export function readToken() {
92
+ try {
93
+ const t = fs.readFileSync(tokenPath(), "utf8").trim();
94
+ return t || null;
95
+ } catch {
96
+ return null;
97
+ }
98
+ }
99
+
100
+ export function generateToken() {
101
+ const t = crypto.randomBytes(32).toString("base64url");
102
+ fs.mkdirSync(dataDir(), { recursive: true });
103
+ fs.writeFileSync(tokenPath(), t + "\n", { mode: 0o600 });
104
+ try {
105
+ fs.chmodSync(tokenPath(), 0o600);
106
+ } catch {}
107
+ return t;
108
+ }
109
+
110
+ export function ensureToken() {
111
+ return readToken() ?? generateToken();
112
+ }
113
+
114
+ const PRIVATE = [
115
+ { re: /^192\.168\./, rank: 0 },
116
+ { re: /^10\./, rank: 1 },
117
+ { re: /^172\.(1[6-9]|2\d|3[01])\./, rank: 2 },
118
+ ];
119
+
120
+ const BAD_NIC = /^(lo|virbr|docker|veth|br-|zt|tailscale|tun|tap|utun|vmnet|wg|ppp|ipoib|wwan|rmnet)/i;
121
+
122
+ export function lanIp() {
123
+ const ifaces = os.networkInterfaces();
124
+ const candidates = [];
125
+ for (const [name, addrs] of Object.entries(ifaces)) {
126
+ if (BAD_NIC.test(name)) continue;
127
+ for (const addr of addrs || []) {
128
+ if (addr.family !== "IPv4" || addr.internal) continue;
129
+ const priv = PRIVATE.find((p) => p.re.test(addr.address));
130
+ if (!priv) continue;
131
+ candidates.push({ address: addr.address, rank: priv.rank, name });
132
+ }
133
+ }
134
+ if (candidates.length === 0) return null;
135
+ candidates.sort((a, b) => a.rank - b.rank);
136
+ return candidates[0].address;
137
+ }