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.
- package/README.md +78 -0
- package/experiments/spike-shell-stream/bin/analytics.js +209 -0
- package/experiments/spike-shell-stream/bin/sema.js +322 -0
- package/experiments/spike-shell-stream/bin/start.js +387 -0
- package/experiments/spike-shell-stream/mac-agent/agent.js +450 -0
- package/experiments/spike-shell-stream/mac-agent/analyzer.js +189 -0
- package/experiments/spike-shell-stream/mac-agent/analyzer.test.js +307 -0
- package/experiments/spike-shell-stream/mac-agent/session.js +38 -0
- package/experiments/spike-shell-stream/mobile-web/inbox.html +431 -0
- package/experiments/spike-shell-stream/mobile-web/index.html +1093 -0
- package/experiments/spike-shell-stream/mobile-web/landing.html +586 -0
- package/experiments/spike-shell-stream/mobile-web/pair.html +304 -0
- package/experiments/spike-shell-stream/relay-server/server.js +1085 -0
- package/experiments/spike-shell-stream/shared/crypto.js +138 -0
- package/experiments/spike-shell-stream/shared/crypto.test.js +350 -0
- package/package.json +52 -0
|
@@ -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
|
+
}
|