sema-cli 0.1.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,138 @@
1
+ const crypto = require("node:crypto");
2
+
3
+ /**
4
+ * Generate X25519 key pair for E2E encryption.
5
+ * @returns {{ privateKey: KeyObject, publicKey: KeyObject }}
6
+ */
7
+ function generateKeyPair() {
8
+ return crypto.generateKeyPairSync("x25519");
9
+ }
10
+
11
+ /**
12
+ * Export public key to base64-encoded SPKI DER format for transmission.
13
+ * @param {KeyObject} publicKey
14
+ * @returns {string} base64-encoded public key
15
+ */
16
+ function exportPublicKey(publicKey) {
17
+ return publicKey.export({ type: "spki", format: "der" }).toString("base64");
18
+ }
19
+
20
+ /**
21
+ * Export private key to base64-encoded PKCS8 DER format for transmission.
22
+ * @param {KeyObject} privateKey
23
+ * @returns {string} base64-encoded private key
24
+ */
25
+ function exportPrivateKey(privateKey) {
26
+ return privateKey.export({ type: "pkcs8", format: "der" }).toString("base64");
27
+ }
28
+
29
+ /**
30
+ * Import public key from base64-encoded SPKI DER format.
31
+ * @param {string} base64 - base64-encoded public key
32
+ * @returns {KeyObject}
33
+ */
34
+ function importPublicKey(base64) {
35
+ return crypto.createPublicKey({
36
+ key: Buffer.from(base64, "base64"),
37
+ format: "der",
38
+ type: "spki",
39
+ });
40
+ }
41
+
42
+ /**
43
+ * Import private key from base64-encoded PKCS8 DER format.
44
+ * @param {string} base64 - base64-encoded private key
45
+ * @returns {KeyObject}
46
+ */
47
+ function importPrivateKey(base64) {
48
+ return crypto.createPrivateKey({
49
+ key: Buffer.from(base64, "base64"),
50
+ format: "der",
51
+ type: "pkcs8",
52
+ });
53
+ }
54
+
55
+ /**
56
+ * Derive shared secret using X25519 Diffie-Hellman.
57
+ * @param {KeyObject} privateKey - own private key
58
+ * @param {KeyObject} peerPublicKey - peer's public key
59
+ * @returns {Buffer} 32-byte shared secret
60
+ */
61
+ function deriveSharedSecret(privateKey, peerPublicKey) {
62
+ return crypto.diffieHellman({ privateKey, publicKey: peerPublicKey });
63
+ }
64
+
65
+ /**
66
+ * Derive AES-256 key from shared secret using HKDF.
67
+ * Salt and info are explicit Buffers to ensure consistency with Web Crypto API.
68
+ * @param {Buffer} sharedSecret - X25519 shared secret
69
+ * @param {Buffer} [salt] - HKDF salt (default: "sema-e2e")
70
+ * @returns {Buffer} 32-byte AES-256 key
71
+ */
72
+ function deriveAesKey(sharedSecret, salt = Buffer.from("sema-e2e", "utf8")) {
73
+ const info = Buffer.from("aes-256-key", "utf8");
74
+ // hkdfSync returns ArrayBuffer, must convert to Buffer for createCipheriv
75
+ return Buffer.from(crypto.hkdfSync("sha256", sharedSecret, salt, info, 32));
76
+ }
77
+
78
+ /**
79
+ * Encrypt plaintext using AES-256-GCM.
80
+ * @param {string} plaintext - UTF-8 plaintext
81
+ * @param {Buffer} key - 32-byte AES-256 key
82
+ * @returns {{ iv: string, ct: string, tag: string }} base64-encoded iv, ciphertext, and auth tag
83
+ */
84
+ function encrypt(plaintext, key) {
85
+ const iv = crypto.randomBytes(12); // 96-bit nonce
86
+ const cipher = crypto.createCipheriv("aes-256-gcm", key, iv);
87
+ const ct = Buffer.concat([cipher.update(plaintext, "utf8"), cipher.final()]);
88
+ const tag = cipher.getAuthTag();
89
+ return {
90
+ iv: iv.toString("base64"),
91
+ ct: ct.toString("base64"),
92
+ tag: tag.toString("base64"),
93
+ };
94
+ }
95
+
96
+ /**
97
+ * Decrypt ciphertext using AES-256-GCM.
98
+ * @param {{ iv: string, ct: string, tag: string }} encrypted - base64-encoded components
99
+ * @param {Buffer} key - 32-byte AES-256 key
100
+ * @returns {string} UTF-8 plaintext
101
+ * @throws {Error} if authentication tag is invalid or key is wrong
102
+ */
103
+ function decrypt({ iv, ct, tag }, key) {
104
+ const decipher = crypto.createDecipheriv("aes-256-gcm", key, Buffer.from(iv, "base64"));
105
+ decipher.setAuthTag(Buffer.from(tag, "base64"));
106
+ // update() returns Buffer, must explicitly convert to utf8 to avoid corruption with non-ASCII
107
+ return decipher.update(Buffer.from(ct, "base64")).toString("utf8") + decipher.final("utf8");
108
+ }
109
+
110
+ /**
111
+ * Compute human-verifiable fingerprint from X25519 shared secret.
112
+ * Both endpoints independently derive the same fingerprint —
113
+ * matching values prove no MITM substituted keys.
114
+ * @param {Buffer} sharedSecret - 32-byte X25519 shared secret
115
+ * @returns {string} fingerprint as "xx:xx:xx:xx:xx:xx" (6 hex bytes)
116
+ */
117
+ function computeFingerprint(sharedSecret) {
118
+ const hash = crypto.createHash("sha256")
119
+ .update("sema-fp")
120
+ .update(sharedSecret)
121
+ .digest();
122
+ return Array.from(hash.subarray(0, 6))
123
+ .map(b => b.toString(16).padStart(2, "0"))
124
+ .join(":");
125
+ }
126
+
127
+ module.exports = {
128
+ generateKeyPair,
129
+ exportPublicKey,
130
+ exportPrivateKey,
131
+ importPublicKey,
132
+ importPrivateKey,
133
+ deriveSharedSecret,
134
+ deriveAesKey,
135
+ encrypt,
136
+ decrypt,
137
+ computeFingerprint,
138
+ };
@@ -0,0 +1,350 @@
1
+ const { describe, it } = require("node:test");
2
+ const assert = require("node:assert/strict");
3
+ const {
4
+ generateKeyPair,
5
+ exportPublicKey,
6
+ importPublicKey,
7
+ deriveSharedSecret,
8
+ deriveAesKey,
9
+ encrypt,
10
+ decrypt,
11
+ computeFingerprint,
12
+ } = require("./crypto");
13
+
14
+ describe("generateKeyPair", () => {
15
+ it("generates valid X25519 key pair", () => {
16
+ const keyPair = generateKeyPair();
17
+ assert.ok(keyPair.privateKey, "privateKey should exist");
18
+ assert.ok(keyPair.publicKey, "publicKey should exist");
19
+ assert.strictEqual(keyPair.privateKey.asymmetricKeyType, "x25519");
20
+ assert.strictEqual(keyPair.publicKey.asymmetricKeyType, "x25519");
21
+ });
22
+
23
+ it("generates unique key pairs", () => {
24
+ const kp1 = generateKeyPair();
25
+ const kp2 = generateKeyPair();
26
+ const pub1 = exportPublicKey(kp1.publicKey);
27
+ const pub2 = exportPublicKey(kp2.publicKey);
28
+ assert.notStrictEqual(pub1, pub2, "different key pairs should have different public keys");
29
+ });
30
+ });
31
+
32
+ describe("exportPublicKey / importPublicKey", () => {
33
+ it("roundtrip preserves public key", () => {
34
+ const keyPair = generateKeyPair();
35
+ const exported = exportPublicKey(keyPair.publicKey);
36
+ assert.strictEqual(typeof exported, "string");
37
+ assert.ok(exported.length > 0, "exported key should not be empty");
38
+
39
+ const imported = importPublicKey(exported);
40
+ assert.strictEqual(imported.asymmetricKeyType, "x25519");
41
+
42
+ // Verify by re-exporting
43
+ const reexported = exportPublicKey(imported);
44
+ assert.strictEqual(reexported, exported, "re-exported key should match original");
45
+ });
46
+
47
+ it("exported key is valid base64", () => {
48
+ const keyPair = generateKeyPair();
49
+ const exported = exportPublicKey(keyPair.publicKey);
50
+ // Should not throw
51
+ Buffer.from(exported, "base64");
52
+ });
53
+ });
54
+
55
+ describe("deriveSharedSecret", () => {
56
+ it("both parties derive identical shared secret", () => {
57
+ const alice = generateKeyPair();
58
+ const bob = generateKeyPair();
59
+
60
+ const aliceSecret = deriveSharedSecret(alice.privateKey, bob.publicKey);
61
+ const bobSecret = deriveSharedSecret(bob.privateKey, alice.publicKey);
62
+
63
+ assert.ok(Buffer.isBuffer(aliceSecret), "shared secret should be Buffer");
64
+ assert.strictEqual(aliceSecret.length, 32, "shared secret should be 32 bytes");
65
+ assert.deepStrictEqual(aliceSecret, bobSecret, "both parties should derive same secret");
66
+ });
67
+
68
+ it("shared secret differs for different key pairs", () => {
69
+ const alice = generateKeyPair();
70
+ const bob = generateKeyPair();
71
+ const charlie = generateKeyPair();
72
+
73
+ const abSecret = deriveSharedSecret(alice.privateKey, bob.publicKey);
74
+ const acSecret = deriveSharedSecret(alice.privateKey, charlie.publicKey);
75
+
76
+ assert.notDeepStrictEqual(abSecret, acSecret, "different peers should yield different secrets");
77
+ });
78
+ });
79
+
80
+ describe("deriveAesKey", () => {
81
+ it("derives 32-byte AES key from shared secret", () => {
82
+ const alice = generateKeyPair();
83
+ const bob = generateKeyPair();
84
+ const sharedSecret = deriveSharedSecret(alice.privateKey, bob.publicKey);
85
+
86
+ const aesKey = deriveAesKey(sharedSecret);
87
+ assert.ok(Buffer.isBuffer(aesKey), "AES key should be Buffer");
88
+ assert.strictEqual(aesKey.length, 32, "AES key should be 32 bytes (256 bits)");
89
+ });
90
+
91
+ it("same shared secret produces same AES key", () => {
92
+ const alice = generateKeyPair();
93
+ const bob = generateKeyPair();
94
+ const secret = deriveSharedSecret(alice.privateKey, bob.publicKey);
95
+
96
+ const key1 = deriveAesKey(secret);
97
+ const key2 = deriveAesKey(secret);
98
+
99
+ assert.deepStrictEqual(key1, key2, "same secret should produce same key");
100
+ });
101
+
102
+ it("different salt produces different AES key", () => {
103
+ const alice = generateKeyPair();
104
+ const bob = generateKeyPair();
105
+ const secret = deriveSharedSecret(alice.privateKey, bob.publicKey);
106
+
107
+ const key1 = deriveAesKey(secret);
108
+ const key2 = deriveAesKey(secret, Buffer.from("different-salt", "utf8"));
109
+
110
+ assert.notDeepStrictEqual(key1, key2, "different salt should produce different key");
111
+ });
112
+ });
113
+
114
+ describe("encrypt / decrypt", () => {
115
+ it("roundtrip preserves plaintext", () => {
116
+ const alice = generateKeyPair();
117
+ const bob = generateKeyPair();
118
+ const secret = deriveSharedSecret(alice.privateKey, bob.publicKey);
119
+ const key = deriveAesKey(secret);
120
+
121
+ const plaintext = "Hello, E2E encryption!";
122
+ const encrypted = encrypt(plaintext, key);
123
+
124
+ assert.strictEqual(typeof encrypted.iv, "string");
125
+ assert.strictEqual(typeof encrypted.ct, "string");
126
+ assert.strictEqual(typeof encrypted.tag, "string");
127
+
128
+ const decrypted = decrypt(encrypted, key);
129
+ assert.strictEqual(decrypted, plaintext);
130
+ });
131
+
132
+ it("handles non-ASCII characters", () => {
133
+ const keyPair = generateKeyPair();
134
+ const secret = deriveSharedSecret(keyPair.privateKey, keyPair.publicKey);
135
+ const key = deriveAesKey(secret);
136
+
137
+ const plaintext = "你好世界 🌍 Привет мир";
138
+ const encrypted = encrypt(plaintext, key);
139
+ const decrypted = decrypt(encrypted, key);
140
+
141
+ assert.strictEqual(decrypted, plaintext);
142
+ });
143
+
144
+ it("handles empty string", () => {
145
+ const keyPair = generateKeyPair();
146
+ const secret = deriveSharedSecret(keyPair.privateKey, keyPair.publicKey);
147
+ const key = deriveAesKey(secret);
148
+
149
+ const plaintext = "";
150
+ const encrypted = encrypt(plaintext, key);
151
+ const decrypted = decrypt(encrypted, key);
152
+
153
+ assert.strictEqual(decrypted, plaintext);
154
+ });
155
+
156
+ it("handles large payload", () => {
157
+ const keyPair = generateKeyPair();
158
+ const secret = deriveSharedSecret(keyPair.privateKey, keyPair.publicKey);
159
+ const key = deriveAesKey(secret);
160
+
161
+ const plaintext = "x".repeat(10000);
162
+ const encrypted = encrypt(plaintext, key);
163
+ const decrypted = decrypt(encrypted, key);
164
+
165
+ assert.strictEqual(decrypted, plaintext);
166
+ });
167
+
168
+ it("each encryption produces different ciphertext (random IV)", () => {
169
+ const keyPair = generateKeyPair();
170
+ const secret = deriveSharedSecret(keyPair.privateKey, keyPair.publicKey);
171
+ const key = deriveAesKey(secret);
172
+
173
+ const plaintext = "same message";
174
+ const enc1 = encrypt(plaintext, key);
175
+ const enc2 = encrypt(plaintext, key);
176
+
177
+ assert.notStrictEqual(enc1.iv, enc2.iv, "IVs should differ");
178
+ assert.notStrictEqual(enc1.ct, enc2.ct, "ciphertexts should differ");
179
+ });
180
+
181
+ it("decryption fails with wrong key", () => {
182
+ const alice = generateKeyPair();
183
+ const bob = generateKeyPair();
184
+ const charlie = generateKeyPair();
185
+
186
+ const abSecret = deriveSharedSecret(alice.privateKey, bob.publicKey);
187
+ const acSecret = deriveSharedSecret(alice.privateKey, charlie.publicKey);
188
+
189
+ const keyAB = deriveAesKey(abSecret);
190
+ const keyAC = deriveAesKey(acSecret);
191
+
192
+ const encrypted = encrypt("secret message", keyAB);
193
+
194
+ assert.throws(
195
+ () => decrypt(encrypted, keyAC),
196
+ /Unsupported state or unable to/,
197
+ "decryption with wrong key should fail"
198
+ );
199
+ });
200
+
201
+ it("decryption fails with tampered ciphertext", () => {
202
+ const keyPair = generateKeyPair();
203
+ const secret = deriveSharedSecret(keyPair.privateKey, keyPair.publicKey);
204
+ const key = deriveAesKey(secret);
205
+
206
+ const encrypted = encrypt("test", key);
207
+ // Tamper with ciphertext
208
+ const tampered = {
209
+ ...encrypted,
210
+ ct: encrypted.ct.slice(0, -2) + "xx",
211
+ };
212
+
213
+ assert.throws(
214
+ () => decrypt(tampered, key),
215
+ /Unsupported state or unable to/,
216
+ "decryption with tampered ciphertext should fail"
217
+ );
218
+ });
219
+
220
+ it("decryption fails with tampered auth tag", () => {
221
+ const keyPair = generateKeyPair();
222
+ const secret = deriveSharedSecret(keyPair.privateKey, keyPair.publicKey);
223
+ const key = deriveAesKey(secret);
224
+
225
+ const encrypted = encrypt("test", key);
226
+ // Tamper with tag
227
+ const tampered = {
228
+ ...encrypted,
229
+ tag: "AAAAAAAAAAAAAAAAAAAAAA==", // fake tag
230
+ };
231
+
232
+ assert.throws(
233
+ () => decrypt(tampered, key),
234
+ /Unsupported state or unable to/,
235
+ "decryption with tampered tag should fail"
236
+ );
237
+ });
238
+ });
239
+
240
+ describe("full E2E key exchange simulation", () => {
241
+ it("simulates Mac ↔ Mobile key exchange and encrypted communication", () => {
242
+ // Mac generates keypair
243
+ const macKeyPair = generateKeyPair();
244
+ const macPublicKeyExport = exportPublicKey(macKeyPair.publicKey);
245
+
246
+ // Mobile receives macPublicKey (via pairing channel)
247
+ const macPublicKeyOnMobile = importPublicKey(macPublicKeyExport);
248
+
249
+ // Mobile generates keypair
250
+ const mobileKeyPair = generateKeyPair();
251
+ const mobilePublicKeyExport = exportPublicKey(mobileKeyPair.publicKey);
252
+
253
+ // Mac receives mobilePublicKey (via WebSocket)
254
+ const mobilePublicKeyOnMac = importPublicKey(mobilePublicKeyExport);
255
+
256
+ // Both derive shared secret
257
+ const macSharedSecret = deriveSharedSecret(macKeyPair.privateKey, mobilePublicKeyOnMac);
258
+ const mobileSharedSecret = deriveSharedSecret(mobileKeyPair.privateKey, macPublicKeyOnMobile);
259
+
260
+ assert.deepStrictEqual(macSharedSecret, mobileSharedSecret, "shared secrets must match");
261
+
262
+ // Both derive AES key
263
+ const macAesKey = deriveAesKey(macSharedSecret);
264
+ const mobileAesKey = deriveAesKey(mobileSharedSecret);
265
+
266
+ assert.deepStrictEqual(macAesKey, mobileAesKey, "AES keys must match");
267
+
268
+ // Mac encrypts output → Mobile decrypts
269
+ const terminalOutput = "$ ls -la\ntotal 42\ndrwxr-xr-x 5 user staff 160 Jan 1 00:00 .";
270
+ const encryptedOutput = encrypt(terminalOutput, macAesKey);
271
+ const decryptedOutput = decrypt(encryptedOutput, mobileAesKey);
272
+
273
+ assert.strictEqual(decryptedOutput, terminalOutput);
274
+
275
+ // Mobile encrypts input → Mac decrypts
276
+ const userInput = "cd ~/projects";
277
+ const encryptedInput = encrypt(userInput, mobileAesKey);
278
+ const decryptedInput = decrypt(encryptedInput, macAesKey);
279
+
280
+ assert.strictEqual(decryptedInput, userInput);
281
+ });
282
+ });
283
+
284
+ describe("computeFingerprint", () => {
285
+ it("returns correct format: xx:xx:xx:xx:xx:xx (17 chars, lowercase hex)", () => {
286
+ const kp1 = generateKeyPair();
287
+ const kp2 = generateKeyPair();
288
+ const secret = deriveSharedSecret(kp1.privateKey, kp2.publicKey);
289
+ const fp = computeFingerprint(secret);
290
+ assert.strictEqual(fp.length, 17);
291
+ assert.match(fp, /^[0-9a-f]{2}(:[0-9a-f]{2}){5}$/);
292
+ });
293
+
294
+ it("is deterministic — same input produces same output", () => {
295
+ const secret = Buffer.alloc(32, 0xab);
296
+ const fp1 = computeFingerprint(secret);
297
+ const fp2 = computeFingerprint(secret);
298
+ assert.strictEqual(fp1, fp2);
299
+ });
300
+
301
+ it("same shared secret from both sides produces same fingerprint", () => {
302
+ const mac = generateKeyPair();
303
+ const mobile = generateKeyPair();
304
+ const macSecret = deriveSharedSecret(mac.privateKey, mobile.publicKey);
305
+ const mobileSecret = deriveSharedSecret(mobile.privateKey, mac.publicKey);
306
+ assert.strictEqual(computeFingerprint(macSecret), computeFingerprint(mobileSecret));
307
+ });
308
+
309
+ it("different shared secrets produce different fingerprints", () => {
310
+ const kp1 = generateKeyPair();
311
+ const kp2 = generateKeyPair();
312
+ const kp3 = generateKeyPair();
313
+ const secret1 = deriveSharedSecret(kp1.privateKey, kp2.publicKey);
314
+ const secret2 = deriveSharedSecret(kp1.privateKey, kp3.publicKey);
315
+ assert.notStrictEqual(computeFingerprint(secret1), computeFingerprint(secret2));
316
+ });
317
+
318
+ it("cross-platform equivalence — known input produces expected output", () => {
319
+ // Fixed 32-byte input for reproducible cross-platform test
320
+ const input = Buffer.from("0123456789abcdef0123456789abcdef");
321
+ const fp = computeFingerprint(input);
322
+
323
+ // Verify by computing expected manually with crypto module
324
+ const crypto = require("node:crypto");
325
+ const expected = crypto.createHash("sha256")
326
+ .update("sema-fp")
327
+ .update(input)
328
+ .digest()
329
+ .subarray(0, 6);
330
+ const expectedStr = Array.from(expected)
331
+ .map(b => b.toString(16).padStart(2, "0"))
332
+ .join(":");
333
+
334
+ assert.strictEqual(fp, expectedStr);
335
+ });
336
+
337
+ it("uses domain separator different from HKDF", () => {
338
+ // The fingerprint hash uses "sema-fp" prefix,
339
+ // HKDF uses "sema-e2e" salt. They must produce different outputs
340
+ // even with the same shared secret.
341
+ const secret = Buffer.alloc(32, 0x42);
342
+ const fp = computeFingerprint(secret);
343
+ const aesKey = deriveAesKey(secret);
344
+
345
+ // fp and aesKey hex should be different (they use different derivation paths)
346
+ const fpHex = fp.replace(/:/g, "");
347
+ const aesHex = aesKey.toString("hex").slice(0, 12);
348
+ assert.notStrictEqual(fpHex, aesHex);
349
+ });
350
+ });
package/package.json ADDED
@@ -0,0 +1,52 @@
1
+ {
2
+ "name": "sema-cli",
3
+ "version": "0.1.0",
4
+ "description": "Sema — the signal between you and your agents. A mobile collaboration layer for AI coding agents.",
5
+ "bin": {
6
+ "sema": "experiments/spike-shell-stream/bin/sema.js"
7
+ },
8
+ "files": [
9
+ "experiments/spike-shell-stream/bin/",
10
+ "experiments/spike-shell-stream/relay-server/",
11
+ "experiments/spike-shell-stream/mac-agent/",
12
+ "experiments/spike-shell-stream/mobile-web/",
13
+ "experiments/spike-shell-stream/shared/"
14
+ ],
15
+ "scripts": {
16
+ "start": "node experiments/spike-shell-stream/bin/start.js",
17
+ "spike:relay": "node experiments/spike-shell-stream/relay-server/server.js",
18
+ "spike:agent": "node experiments/spike-shell-stream/mac-agent/agent.js",
19
+ "spike:agent:cli": "CLI_COMMAND=zsh node experiments/spike-shell-stream/mac-agent/agent.js",
20
+ "spike:test-client": "node experiments/spike-shell-stream/test-client.js",
21
+ "spike:clean": "tmux kill-server 2>/dev/null; echo 'tmux sessions cleared'",
22
+ "test": "node --test experiments/spike-shell-stream/shared/crypto.test.js experiments/spike-shell-stream/mac-agent/analyzer.test.js"
23
+ },
24
+ "dependencies": {
25
+ "node-pty-prebuilt-multiarch": "^0.10.1-pre.5",
26
+ "qrcode-terminal": "^0.12.0"
27
+ },
28
+ "engines": {
29
+ "node": ">=22.0.0"
30
+ },
31
+ "os": [
32
+ "darwin"
33
+ ],
34
+ "keywords": [
35
+ "ai",
36
+ "coding",
37
+ "agent",
38
+ "mobile",
39
+ "claude-code",
40
+ "remote",
41
+ "terminal"
42
+ ],
43
+ "license": "MIT",
44
+ "repository": {
45
+ "type": "git",
46
+ "url": "https://github.com/PikuluSama/sema.git"
47
+ },
48
+ "homepage": "https://github.com/PikuluSama/sema#readme",
49
+ "bugs": {
50
+ "url": "https://github.com/PikuluSama/sema/issues"
51
+ }
52
+ }