@moxxy/e2e 0.27.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,57 @@
1
+ /**
2
+ * The agent's self-sovereign identity for proxy: a long-lived Ed25519 keypair.
3
+ *
4
+ * The public key alone determines two things, with no account system involved:
5
+ * - the **fingerprint** carried out-of-band in the QR (`?fp=`), which the
6
+ * phone pins and uses to authenticate the agent during the handshake;
7
+ * - the **uuid** subdomain (`uuid = base32(sha256(pubkey))`), which the relay
8
+ * re-derives after verifying a signature, so only the holder of the private
9
+ * key can register/reclaim it (preimage-bound, like a WireGuard node key).
10
+ *
11
+ * Because the QR pins the fingerprint, the phone can independently recompute the
12
+ * expected uuid and reject any URL whose subdomain doesn't match — making the
13
+ * subdomain itself verifiable, not just trusted.
14
+ */
15
+ import { ed25519 } from '@noble/curves/ed25519.js';
16
+ import { sha256 } from '@noble/hashes/sha2.js';
17
+ import { base32Encode, base64urlDecode, base64urlEncode } from './bytes.js';
18
+ /** Number of base32 chars in the uuid label (16 × 5 = 80 bits of preimage strength). */
19
+ export const UUID_LABEL_LENGTH = 16;
20
+ /** Generate a fresh agent identity. */
21
+ export function generateIdentity() {
22
+ const { secretKey, publicKey } = ed25519.keygen();
23
+ return { secretKey, publicKey };
24
+ }
25
+ /** Recover the public key from a stored secret key. */
26
+ export function publicKeyFromSecret(secretKey) {
27
+ return ed25519.getPublicKey(secretKey);
28
+ }
29
+ /** The QR-carried fingerprint: base64url of the raw 32-byte public key. */
30
+ export function fingerprint(publicKey) {
31
+ return base64urlEncode(publicKey);
32
+ }
33
+ /** Parse a fingerprint string back to the 32-byte public key (throws if malformed). */
34
+ export function publicKeyFromFingerprint(fp) {
35
+ const key = base64urlDecode(fp);
36
+ if (key.length !== 32)
37
+ throw new Error(`bad fingerprint: expected 32 bytes, got ${key.length}`);
38
+ return key;
39
+ }
40
+ /** Derive the stable uuid subdomain label from a public key. */
41
+ export function deriveUuid(publicKey) {
42
+ return base32Encode(sha256(publicKey)).slice(0, UUID_LABEL_LENGTH);
43
+ }
44
+ /** Sign a message with the identity's secret key (Ed25519). */
45
+ export function sign(message, secretKey) {
46
+ return ed25519.sign(message, secretKey);
47
+ }
48
+ /** Verify an Ed25519 signature against a public key. */
49
+ export function verify(signature, message, publicKey) {
50
+ try {
51
+ return ed25519.verify(signature, message, publicKey);
52
+ }
53
+ catch {
54
+ return false;
55
+ }
56
+ }
57
+ //# sourceMappingURL=identity.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"identity.js","sourceRoot":"","sources":["../src/identity.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;GAaG;AACH,OAAO,EAAE,OAAO,EAAE,MAAM,0BAA0B,CAAC;AACnD,OAAO,EAAE,MAAM,EAAE,MAAM,uBAAuB,CAAC;AAC/C,OAAO,EAAE,YAAY,EAAE,eAAe,EAAE,eAAe,EAAE,MAAM,YAAY,CAAC;AAE5E,wFAAwF;AACxF,MAAM,CAAC,MAAM,iBAAiB,GAAG,EAAE,CAAC;AASpC,uCAAuC;AACvC,MAAM,UAAU,gBAAgB;IAC9B,MAAM,EAAE,SAAS,EAAE,SAAS,EAAE,GAAG,OAAO,CAAC,MAAM,EAAE,CAAC;IAClD,OAAO,EAAE,SAAS,EAAE,SAAS,EAAE,CAAC;AAClC,CAAC;AAED,uDAAuD;AACvD,MAAM,UAAU,mBAAmB,CAAC,SAAqB;IACvD,OAAO,OAAO,CAAC,YAAY,CAAC,SAAS,CAAC,CAAC;AACzC,CAAC;AAED,2EAA2E;AAC3E,MAAM,UAAU,WAAW,CAAC,SAAqB;IAC/C,OAAO,eAAe,CAAC,SAAS,CAAC,CAAC;AACpC,CAAC;AAED,uFAAuF;AACvF,MAAM,UAAU,wBAAwB,CAAC,EAAU;IACjD,MAAM,GAAG,GAAG,eAAe,CAAC,EAAE,CAAC,CAAC;IAChC,IAAI,GAAG,CAAC,MAAM,KAAK,EAAE;QAAE,MAAM,IAAI,KAAK,CAAC,2CAA2C,GAAG,CAAC,MAAM,EAAE,CAAC,CAAC;IAChG,OAAO,GAAG,CAAC;AACb,CAAC;AAED,gEAAgE;AAChE,MAAM,UAAU,UAAU,CAAC,SAAqB;IAC9C,OAAO,YAAY,CAAC,MAAM,CAAC,SAAS,CAAC,CAAC,CAAC,KAAK,CAAC,CAAC,EAAE,iBAAiB,CAAC,CAAC;AACrE,CAAC;AAED,+DAA+D;AAC/D,MAAM,UAAU,IAAI,CAAC,OAAmB,EAAE,SAAqB;IAC7D,OAAO,OAAO,CAAC,IAAI,CAAC,OAAO,EAAE,SAAS,CAAC,CAAC;AAC1C,CAAC;AAED,wDAAwD;AACxD,MAAM,UAAU,MAAM,CACpB,SAAqB,EACrB,OAAmB,EACnB,SAAqB;IAErB,IAAI,CAAC;QACH,OAAO,OAAO,CAAC,MAAM,CAAC,SAAS,EAAE,OAAO,EAAE,SAAS,CAAC,CAAC;IACvD,CAAC;IAAC,MAAM,CAAC;QACP,OAAO,KAAK,CAAC;IACf,CAAC;AACH,CAAC"}
@@ -0,0 +1,13 @@
1
+ /**
2
+ * `@moxxy/e2e` — the end-to-end secure channel for the proxy tunnel.
3
+ *
4
+ * This `.` entry is **pure JS with no Node built-ins**, so it bundles under
5
+ * Metro/React Native. Key persistence (which needs the filesystem) lives in the
6
+ * separate `@moxxy/e2e/node` entry.
7
+ */
8
+ export { generateIdentity, publicKeyFromSecret, fingerprint, publicKeyFromFingerprint, deriveUuid, sign, verify, UUID_LABEL_LENGTH, type Identity, } from './identity.js';
9
+ export { startInitiator, respond, finishInitiator, CLIENT_HELLO_LEN, SERVER_HELLO_LEN, type SessionKeys, type InitiatorState, } from './handshake.js';
10
+ export { FrameSealer, FrameOpener } from './frame.js';
11
+ export { connectInitiator, connectResponder, type SecureChannel, type MessageTransport, } from './channel.js';
12
+ export { base64urlEncode, base64urlDecode, base32Encode, constantTimeEqual, utf8, utf8Decode, } from './bytes.js';
13
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AACH,OAAO,EACL,gBAAgB,EAChB,mBAAmB,EACnB,WAAW,EACX,wBAAwB,EACxB,UAAU,EACV,IAAI,EACJ,MAAM,EACN,iBAAiB,EACjB,KAAK,QAAQ,GACd,MAAM,eAAe,CAAC;AAEvB,OAAO,EACL,cAAc,EACd,OAAO,EACP,eAAe,EACf,gBAAgB,EAChB,gBAAgB,EAChB,KAAK,WAAW,EAChB,KAAK,cAAc,GACpB,MAAM,gBAAgB,CAAC;AAExB,OAAO,EAAE,WAAW,EAAE,WAAW,EAAE,MAAM,YAAY,CAAC;AAEtD,OAAO,EACL,gBAAgB,EAChB,gBAAgB,EAChB,KAAK,aAAa,EAClB,KAAK,gBAAgB,GACtB,MAAM,cAAc,CAAC;AAEtB,OAAO,EACL,eAAe,EACf,eAAe,EACf,YAAY,EACZ,iBAAiB,EACjB,IAAI,EACJ,UAAU,GACX,MAAM,YAAY,CAAC"}
package/dist/index.js ADDED
@@ -0,0 +1,13 @@
1
+ /**
2
+ * `@moxxy/e2e` — the end-to-end secure channel for the proxy tunnel.
3
+ *
4
+ * This `.` entry is **pure JS with no Node built-ins**, so it bundles under
5
+ * Metro/React Native. Key persistence (which needs the filesystem) lives in the
6
+ * separate `@moxxy/e2e/node` entry.
7
+ */
8
+ export { generateIdentity, publicKeyFromSecret, fingerprint, publicKeyFromFingerprint, deriveUuid, sign, verify, UUID_LABEL_LENGTH, } from './identity.js';
9
+ export { startInitiator, respond, finishInitiator, CLIENT_HELLO_LEN, SERVER_HELLO_LEN, } from './handshake.js';
10
+ export { FrameSealer, FrameOpener } from './frame.js';
11
+ export { connectInitiator, connectResponder, } from './channel.js';
12
+ export { base64urlEncode, base64urlDecode, base32Encode, constantTimeEqual, utf8, utf8Decode, } from './bytes.js';
13
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AACH,OAAO,EACL,gBAAgB,EAChB,mBAAmB,EACnB,WAAW,EACX,wBAAwB,EACxB,UAAU,EACV,IAAI,EACJ,MAAM,EACN,iBAAiB,GAElB,MAAM,eAAe,CAAC;AAEvB,OAAO,EACL,cAAc,EACd,OAAO,EACP,eAAe,EACf,gBAAgB,EAChB,gBAAgB,GAGjB,MAAM,gBAAgB,CAAC;AAExB,OAAO,EAAE,WAAW,EAAE,WAAW,EAAE,MAAM,YAAY,CAAC;AAEtD,OAAO,EACL,gBAAgB,EAChB,gBAAgB,GAGjB,MAAM,cAAc,CAAC;AAEtB,OAAO,EACL,eAAe,EACf,eAAe,EACf,YAAY,EACZ,iBAAiB,EACjB,IAAI,EACJ,UAAU,GACX,MAAM,YAAY,CAAC"}
package/dist/node.d.ts ADDED
@@ -0,0 +1,10 @@
1
+ import { type Identity } from './identity.js';
2
+ /** Default on-disk location of the agent's proxy identity secret key. */
3
+ export declare function defaultIdentityPath(): string;
4
+ /**
5
+ * Load the agent identity from disk, generating and persisting a fresh one on
6
+ * first run. The returned identity is stable across restarts, so the agent keeps
7
+ * the same uuid subdomain and the same pinned fingerprint.
8
+ */
9
+ export declare function loadOrCreateIdentity(path?: string): Promise<Identity>;
10
+ //# sourceMappingURL=node.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"node.d.ts","sourceRoot":"","sources":["../src/node.ts"],"names":[],"mappings":"AASA,OAAO,EAAyC,KAAK,QAAQ,EAAE,MAAM,eAAe,CAAC;AAErF,yEAAyE;AACzE,wBAAgB,mBAAmB,IAAI,MAAM,CAE5C;AAED;;;;GAIG;AACH,wBAAsB,oBAAoB,CAAC,IAAI,GAAE,MAA8B,GAAG,OAAO,CAAC,QAAQ,CAAC,CAqBlG"}
package/dist/node.js ADDED
@@ -0,0 +1,45 @@
1
+ /**
2
+ * Node-only identity persistence for `@moxxy/e2e`. Kept off the `.` export so the
3
+ * RN bundle never pulls `node:fs`. The secret key is stored once per install at
4
+ * `~/.moxxy/proxy-identity.key` (mode 0600, atomic write — same invariant the
5
+ * vault/sessions use), and the public key is derived from it on load.
6
+ */
7
+ import { readFile } from 'node:fs/promises';
8
+ import { moxxyPath, writeFileAtomic } from '@moxxy/sdk/server';
9
+ import { bytesToHex, hexToBytes } from '@noble/hashes/utils.js';
10
+ import { generateIdentity, publicKeyFromSecret } from './identity.js';
11
+ /** Default on-disk location of the agent's proxy identity secret key. */
12
+ export function defaultIdentityPath() {
13
+ return moxxyPath('proxy-identity.key');
14
+ }
15
+ /**
16
+ * Load the agent identity from disk, generating and persisting a fresh one on
17
+ * first run. The returned identity is stable across restarts, so the agent keeps
18
+ * the same uuid subdomain and the same pinned fingerprint.
19
+ */
20
+ export async function loadOrCreateIdentity(path = defaultIdentityPath()) {
21
+ let hex = null;
22
+ try {
23
+ hex = (await readFile(path, 'utf8')).trim();
24
+ }
25
+ catch (err) {
26
+ // Missing file → first run (generate below). Anything else (e.g. EACCES) is real.
27
+ if (err.code !== 'ENOENT')
28
+ throw err;
29
+ }
30
+ if (hex !== null) {
31
+ try {
32
+ const secretKey = hexToBytes(hex);
33
+ if (secretKey.length === 32) {
34
+ return { secretKey, publicKey: publicKeyFromSecret(secretKey) };
35
+ }
36
+ }
37
+ catch {
38
+ // Malformed contents → fall through and regenerate.
39
+ }
40
+ }
41
+ const identity = generateIdentity();
42
+ await writeFileAtomic(path, bytesToHex(identity.secretKey), { mode: 0o600 });
43
+ return identity;
44
+ }
45
+ //# sourceMappingURL=node.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"node.js","sourceRoot":"","sources":["../src/node.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AACH,OAAO,EAAE,QAAQ,EAAE,MAAM,kBAAkB,CAAC;AAC5C,OAAO,EAAE,SAAS,EAAE,eAAe,EAAE,MAAM,mBAAmB,CAAC;AAC/D,OAAO,EAAE,UAAU,EAAE,UAAU,EAAE,MAAM,wBAAwB,CAAC;AAChE,OAAO,EAAE,gBAAgB,EAAE,mBAAmB,EAAiB,MAAM,eAAe,CAAC;AAErF,yEAAyE;AACzE,MAAM,UAAU,mBAAmB;IACjC,OAAO,SAAS,CAAC,oBAAoB,CAAC,CAAC;AACzC,CAAC;AAED;;;;GAIG;AACH,MAAM,CAAC,KAAK,UAAU,oBAAoB,CAAC,OAAe,mBAAmB,EAAE;IAC7E,IAAI,GAAG,GAAkB,IAAI,CAAC;IAC9B,IAAI,CAAC;QACH,GAAG,GAAG,CAAC,MAAM,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC,CAAC,CAAC,IAAI,EAAE,CAAC;IAC9C,CAAC;IAAC,OAAO,GAAG,EAAE,CAAC;QACb,kFAAkF;QAClF,IAAK,GAA6B,CAAC,IAAI,KAAK,QAAQ;YAAE,MAAM,GAAG,CAAC;IAClE,CAAC;IACD,IAAI,GAAG,KAAK,IAAI,EAAE,CAAC;QACjB,IAAI,CAAC;YACH,MAAM,SAAS,GAAG,UAAU,CAAC,GAAG,CAAC,CAAC;YAClC,IAAI,SAAS,CAAC,MAAM,KAAK,EAAE,EAAE,CAAC;gBAC5B,OAAO,EAAE,SAAS,EAAE,SAAS,EAAE,mBAAmB,CAAC,SAAS,CAAC,EAAE,CAAC;YAClE,CAAC;QACH,CAAC;QAAC,MAAM,CAAC;YACP,oDAAoD;QACtD,CAAC;IACH,CAAC;IACD,MAAM,QAAQ,GAAG,gBAAgB,EAAE,CAAC;IACpC,MAAM,eAAe,CAAC,IAAI,EAAE,UAAU,CAAC,QAAQ,CAAC,SAAS,CAAC,EAAE,EAAE,IAAI,EAAE,KAAK,EAAE,CAAC,CAAC;IAC7E,OAAO,QAAQ,CAAC;AAClB,CAAC"}
package/package.json ADDED
@@ -0,0 +1,62 @@
1
+ {
2
+ "name": "@moxxy/e2e",
3
+ "version": "0.27.0",
4
+ "description": "End-to-end-encrypted secure channel for the proxy tunnel: a signed-ephemeral-ECDH handshake (Ed25519 identity pinned out-of-band via the QR fingerprint) + XChaCha20-Poly1305 framing, so a relay between agent and phone sees only ciphertext it cannot forge or read. Pure JS (@noble), no Node built-ins on the '.' export, so it bundles cleanly under Metro/React Native.",
5
+ "keywords": [
6
+ "moxxy",
7
+ "e2e",
8
+ "encryption",
9
+ "noble"
10
+ ],
11
+ "homepage": "https://moxxy.ai",
12
+ "bugs": {
13
+ "url": "https://github.com/moxxy-ai/moxxy/issues"
14
+ },
15
+ "repository": {
16
+ "type": "git",
17
+ "url": "git+https://github.com/moxxy-ai/moxxy.git",
18
+ "directory": "packages/e2e"
19
+ },
20
+ "author": "Michal Makowski <michal.makowski97@gmail.com>",
21
+ "license": "MIT",
22
+ "publishConfig": {
23
+ "access": "public"
24
+ },
25
+ "type": "module",
26
+ "main": "./dist/index.js",
27
+ "types": "./dist/index.d.ts",
28
+ "exports": {
29
+ ".": {
30
+ "types": "./dist/index.d.ts",
31
+ "import": "./dist/index.js"
32
+ },
33
+ "./node": {
34
+ "types": "./dist/node.d.ts",
35
+ "import": "./dist/node.js"
36
+ }
37
+ },
38
+ "files": [
39
+ "dist",
40
+ "src"
41
+ ],
42
+ "dependencies": {
43
+ "@noble/ciphers": "^2.2.0",
44
+ "@noble/curves": "^2.2.0",
45
+ "@noble/hashes": "^2.2.0",
46
+ "@moxxy/sdk": "0.27.0"
47
+ },
48
+ "devDependencies": {
49
+ "@types/node": "^22.10.0",
50
+ "typescript": "^5.7.3",
51
+ "vitest": "^2.1.8",
52
+ "@moxxy/vitest-preset": "0.0.0",
53
+ "@moxxy/tsconfig": "0.0.0"
54
+ },
55
+ "scripts": {
56
+ "build": "tsc -p tsconfig.json",
57
+ "typecheck": "tsc -p tsconfig.json --noEmit",
58
+ "test": "vitest run",
59
+ "test:watch": "vitest",
60
+ "clean": "rm -rf dist .turbo"
61
+ }
62
+ }
@@ -0,0 +1,71 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import {
3
+ base32Encode,
4
+ base64urlDecode,
5
+ base64urlEncode,
6
+ concatBytes,
7
+ constantTimeEqual,
8
+ readU64be,
9
+ u64be,
10
+ utf8,
11
+ } from './bytes.js';
12
+
13
+ describe('concatBytes', () => {
14
+ it('joins parts in order', () => {
15
+ expect([...concatBytes(new Uint8Array([1, 2]), new Uint8Array([3]))]).toEqual([1, 2, 3]);
16
+ });
17
+ it('handles empty input', () => {
18
+ expect(concatBytes().length).toBe(0);
19
+ });
20
+ });
21
+
22
+ describe('constantTimeEqual', () => {
23
+ it('is true for equal arrays', () => {
24
+ expect(constantTimeEqual(new Uint8Array([1, 2, 3]), new Uint8Array([1, 2, 3]))).toBe(true);
25
+ });
26
+ it('is false for different contents or lengths', () => {
27
+ expect(constantTimeEqual(new Uint8Array([1, 2, 3]), new Uint8Array([1, 2, 4]))).toBe(false);
28
+ expect(constantTimeEqual(new Uint8Array([1, 2]), new Uint8Array([1, 2, 3]))).toBe(false);
29
+ });
30
+ });
31
+
32
+ describe('u64be / readU64be', () => {
33
+ it('round-trips small and large values', () => {
34
+ for (const n of [0, 1, 255, 256, 65535, 2 ** 32, 2 ** 32 + 7, Number.MAX_SAFE_INTEGER]) {
35
+ expect(readU64be(u64be(n))).toBe(n);
36
+ }
37
+ });
38
+ it('rejects out-of-range values', () => {
39
+ expect(() => u64be(-1)).toThrow();
40
+ expect(() => u64be(Number.MAX_SAFE_INTEGER + 1)).toThrow();
41
+ });
42
+ });
43
+
44
+ describe('base64url', () => {
45
+ it('matches a known vector', () => {
46
+ expect(base64urlEncode(utf8('foobar'))).toBe('Zm9vYmFy');
47
+ });
48
+ it('uses url-safe alphabet (- and _, no padding)', () => {
49
+ const encoded = base64urlEncode(new Uint8Array([251, 255, 191, 255]));
50
+ expect(encoded).not.toMatch(/[+/=]/);
51
+ });
52
+ it('round-trips arbitrary bytes of every length mod 3', () => {
53
+ for (let len = 0; len < 35; len++) {
54
+ const bytes = new Uint8Array(len).map((_, i) => (i * 37 + 11) & 0xff);
55
+ expect([...base64urlDecode(base64urlEncode(bytes))]).toEqual([...bytes]);
56
+ }
57
+ });
58
+ it('rejects invalid characters', () => {
59
+ expect(() => base64urlDecode('not valid!')).toThrow();
60
+ });
61
+ });
62
+
63
+ describe('base32Encode', () => {
64
+ it('matches a known lowercase vector', () => {
65
+ expect(base32Encode(utf8('foobar'))).toBe('mzxw6ytboi');
66
+ });
67
+ it('produces only DNS-label-safe chars', () => {
68
+ const out = base32Encode(new Uint8Array(40).map((_, i) => (i * 53) & 0xff));
69
+ expect(out).toMatch(/^[a-z2-7]+$/);
70
+ });
71
+ });
package/src/bytes.ts ADDED
@@ -0,0 +1,146 @@
1
+ /**
2
+ * Small, dependency-free byte helpers shared by the handshake/frame/identity
3
+ * modules. Encodings are hand-rolled (not `node:buffer`) so the `.` export stays
4
+ * RN-safe — React Native has no global `Buffer`.
5
+ */
6
+
7
+ /** Concatenate byte arrays into one fresh `Uint8Array`. */
8
+ export function concatBytes(...parts: readonly Uint8Array[]): Uint8Array {
9
+ let total = 0;
10
+ for (const p of parts) total += p.length;
11
+ const out = new Uint8Array(total);
12
+ let off = 0;
13
+ for (const p of parts) {
14
+ out.set(p, off);
15
+ off += p.length;
16
+ }
17
+ return out;
18
+ }
19
+
20
+ /**
21
+ * Constant-time equality for two byte arrays. Returns false immediately on a
22
+ * length mismatch (lengths here are public — fixed-size keys), then XOR-folds
23
+ * every byte so the compare time doesn't depend on where the first difference
24
+ * is. Used for the pinned-fingerprint check, where a timing leak would let an
25
+ * attacker probe the expected key byte by byte.
26
+ */
27
+ export function constantTimeEqual(a: Uint8Array, b: Uint8Array): boolean {
28
+ if (a.length !== b.length) return false;
29
+ let diff = 0;
30
+ for (let i = 0; i < a.length; i++) diff |= (a[i] as number) ^ (b[i] as number);
31
+ return diff === 0;
32
+ }
33
+
34
+ /** UTF-8 encode without depending on `Buffer` (TextEncoder is in Node + RN). */
35
+ export function utf8(s: string): Uint8Array {
36
+ return new TextEncoder().encode(s);
37
+ }
38
+
39
+ /** UTF-8 decode (TextDecoder is in Node + modern RN/Hermes). */
40
+ export function utf8Decode(b: Uint8Array): string {
41
+ return new TextDecoder().decode(b);
42
+ }
43
+
44
+ /**
45
+ * Big-endian 8-byte encoding of a non-negative JS number. Written as two 32-bit
46
+ * halves to avoid `BigInt`/`setBigUint64` (Hermes/older RN engines are spotty);
47
+ * safe for any sequence counter below `Number.MAX_SAFE_INTEGER`.
48
+ */
49
+ export function u64be(n: number): Uint8Array {
50
+ if (!Number.isInteger(n) || n < 0 || n > Number.MAX_SAFE_INTEGER) {
51
+ throw new RangeError(`u64be: out of range: ${n}`);
52
+ }
53
+ const out = new Uint8Array(8);
54
+ const hi = Math.floor(n / 0x1_0000_0000);
55
+ const lo = n >>> 0;
56
+ out[0] = (hi >>> 24) & 0xff;
57
+ out[1] = (hi >>> 16) & 0xff;
58
+ out[2] = (hi >>> 8) & 0xff;
59
+ out[3] = hi & 0xff;
60
+ out[4] = (lo >>> 24) & 0xff;
61
+ out[5] = (lo >>> 16) & 0xff;
62
+ out[6] = (lo >>> 8) & 0xff;
63
+ out[7] = lo & 0xff;
64
+ return out;
65
+ }
66
+
67
+ /** Decode a big-endian 8-byte counter back to a JS number. */
68
+ export function readU64be(b: Uint8Array, off = 0): number {
69
+ const hi =
70
+ ((b[off] as number) << 24) |
71
+ ((b[off + 1] as number) << 16) |
72
+ ((b[off + 2] as number) << 8) |
73
+ (b[off + 3] as number);
74
+ const lo =
75
+ ((b[off + 4] as number) << 24) |
76
+ ((b[off + 5] as number) << 16) |
77
+ ((b[off + 6] as number) << 8) |
78
+ (b[off + 7] as number);
79
+ return (hi >>> 0) * 0x1_0000_0000 + (lo >>> 0);
80
+ }
81
+
82
+ // ---------------------------------------------------------------------------
83
+ // base64url (RFC 4648 §5, no padding) — used to carry the 32-byte Ed25519
84
+ // public-key fingerprint in the QR/connect URL.
85
+ // ---------------------------------------------------------------------------
86
+
87
+ const B64URL = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_';
88
+
89
+ export function base64urlEncode(bytes: Uint8Array): string {
90
+ let out = '';
91
+ for (let i = 0; i < bytes.length; i += 3) {
92
+ const b0 = bytes[i] as number;
93
+ const b1 = i + 1 < bytes.length ? (bytes[i + 1] as number) : 0;
94
+ const b2 = i + 2 < bytes.length ? (bytes[i + 2] as number) : 0;
95
+ out += B64URL[b0 >> 2];
96
+ out += B64URL[((b0 & 0x03) << 4) | (b1 >> 4)];
97
+ if (i + 1 < bytes.length) out += B64URL[((b1 & 0x0f) << 2) | (b2 >> 6)];
98
+ if (i + 2 < bytes.length) out += B64URL[b2 & 0x3f];
99
+ }
100
+ return out;
101
+ }
102
+
103
+ export function base64urlDecode(s: string): Uint8Array {
104
+ const lut = new Int16Array(128).fill(-1);
105
+ for (let i = 0; i < B64URL.length; i++) lut[B64URL.charCodeAt(i)] = i;
106
+ const clean = s.trim();
107
+ const out: number[] = [];
108
+ let buf = 0;
109
+ let bits = 0;
110
+ for (const ch of clean) {
111
+ const code = ch.charCodeAt(0);
112
+ const v = code < 128 ? lut[code] : -1;
113
+ if (v === undefined || v < 0) throw new Error('base64urlDecode: invalid character');
114
+ buf = (buf << 6) | v;
115
+ bits += 6;
116
+ if (bits >= 8) {
117
+ bits -= 8;
118
+ out.push((buf >> bits) & 0xff);
119
+ }
120
+ }
121
+ return Uint8Array.from(out);
122
+ }
123
+
124
+ // ---------------------------------------------------------------------------
125
+ // base32 (RFC 4648 §6, lowercase, no padding) — used for the uuid subdomain
126
+ // label. Lowercase a-z + 2-7 is a valid DNS label, and the alphabet avoids the
127
+ // visually ambiguous 0/1/8/9.
128
+ // ---------------------------------------------------------------------------
129
+
130
+ const B32 = 'abcdefghijklmnopqrstuvwxyz234567';
131
+
132
+ export function base32Encode(bytes: Uint8Array): string {
133
+ let out = '';
134
+ let buf = 0;
135
+ let bits = 0;
136
+ for (const byte of bytes) {
137
+ buf = (buf << 8) | byte;
138
+ bits += 8;
139
+ while (bits >= 5) {
140
+ bits -= 5;
141
+ out += B32[(buf >> bits) & 0x1f];
142
+ }
143
+ }
144
+ if (bits > 0) out += B32[(buf << (5 - bits)) & 0x1f];
145
+ return out;
146
+ }
@@ -0,0 +1,154 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { utf8 } from './bytes.js';
3
+ import { connectInitiator, connectResponder, type MessageTransport } from './channel.js';
4
+ import { generateIdentity } from './identity.js';
5
+
6
+ /**
7
+ * A pair of in-memory transports modelling the relay link: a message sent on one
8
+ * end is delivered (async, ordered) to the other. An optional per-direction
9
+ * `mutate` hook lets a test play the tampering relay.
10
+ */
11
+ function makePair(opts: {
12
+ mutateAtoB?: (d: Uint8Array) => Uint8Array | null;
13
+ mutateBtoA?: (d: Uint8Array) => Uint8Array | null;
14
+ } = {}): [MessageTransport, MessageTransport] {
15
+ let aMsg: ((d: Uint8Array) => void) | null = null;
16
+ let bMsg: ((d: Uint8Array) => void) | null = null;
17
+ let aClose: (() => void) | null = null;
18
+ let bClose: (() => void) | null = null;
19
+ let open = true;
20
+
21
+ const deliver = (
22
+ to: () => ((d: Uint8Array) => void) | null,
23
+ mutate: ((d: Uint8Array) => Uint8Array | null) | undefined,
24
+ d: Uint8Array,
25
+ ): void => {
26
+ if (!open) return;
27
+ const out = mutate ? mutate(d) : d;
28
+ if (out === null) return; // dropped by the relay
29
+ queueMicrotask(() => to()?.(out));
30
+ };
31
+
32
+ const a: MessageTransport = {
33
+ send: (d) => deliver(() => bMsg, opts.mutateAtoB, d),
34
+ onMessage: (h) => {
35
+ aMsg = h;
36
+ },
37
+ onClose: (h) => {
38
+ aClose = h;
39
+ },
40
+ close: () => {
41
+ if (!open) return;
42
+ open = false;
43
+ queueMicrotask(() => {
44
+ aClose?.();
45
+ bClose?.();
46
+ });
47
+ },
48
+ };
49
+ const b: MessageTransport = {
50
+ send: (d) => deliver(() => aMsg, opts.mutateBtoA, d),
51
+ onMessage: (h) => {
52
+ bMsg = h;
53
+ },
54
+ onClose: (h) => {
55
+ bClose = h;
56
+ },
57
+ close: () => {
58
+ if (!open) return;
59
+ open = false;
60
+ queueMicrotask(() => {
61
+ aClose?.();
62
+ bClose?.();
63
+ });
64
+ },
65
+ };
66
+ return [a, b];
67
+ }
68
+
69
+ const collect = (ch: { onMessage: (cb: (p: Uint8Array) => void) => void }): string[] => {
70
+ const out: string[] = [];
71
+ ch.onMessage((p) => out.push(new TextDecoder().decode(p)));
72
+ return out;
73
+ };
74
+
75
+ describe('SecureChannel', () => {
76
+ it('establishes and exchanges messages both ways', async () => {
77
+ const agent = generateIdentity();
78
+ const [phoneT, agentT] = makePair();
79
+
80
+ const [phone, agentCh] = await Promise.all([
81
+ connectInitiator(phoneT, agent.publicKey),
82
+ connectResponder(agentT, agent),
83
+ ]);
84
+
85
+ const gotByAgent = collect(agentCh);
86
+ const gotByPhone = collect(phone);
87
+
88
+ phone.send(utf8('hello from phone'));
89
+ agentCh.send(utf8('hello from agent'));
90
+ await new Promise((r) => setTimeout(r, 5));
91
+
92
+ expect(gotByAgent).toEqual(['hello from phone']);
93
+ expect(gotByPhone).toEqual(['hello from agent']);
94
+ });
95
+
96
+ it('buffers messages received before a handler is registered', async () => {
97
+ const agent = generateIdentity();
98
+ const [phoneT, agentT] = makePair();
99
+ const [phone, agentCh] = await Promise.all([
100
+ connectInitiator(phoneT, agent.publicKey),
101
+ connectResponder(agentT, agent),
102
+ ]);
103
+
104
+ phone.send(utf8('early'));
105
+ await new Promise((r) => setTimeout(r, 5)); // arrives before onMessage set
106
+
107
+ const got = collect(agentCh); // registered late → should flush backlog
108
+ expect(got).toEqual(['early']);
109
+ });
110
+
111
+ it('rejects the handshake when the phone pins the wrong key (spoofing)', async () => {
112
+ const agent = generateIdentity();
113
+ const attacker = generateIdentity();
114
+ const [phoneT, agentT] = makePair();
115
+
116
+ // The agent responds with its real key, but the phone pins the attacker's.
117
+ void connectResponder(agentT, agent);
118
+ await expect(connectInitiator(phoneT, attacker.publicKey)).rejects.toThrow(/pinned fingerprint/);
119
+ });
120
+
121
+ it('closes the channel when the relay tampers with a frame', async () => {
122
+ const agent = generateIdentity();
123
+ let tamperArmed = false;
124
+ const [phoneT, agentT] = makePair({
125
+ mutateAtoB: (d) => {
126
+ // Leave the handshake (first message) intact; corrupt the first data frame.
127
+ if (!tamperArmed) {
128
+ tamperArmed = true;
129
+ return d;
130
+ }
131
+ const copy = d.slice();
132
+ copy[copy.length - 1] ^= 0x01;
133
+ return copy;
134
+ },
135
+ });
136
+
137
+ const [phone, agentCh] = await Promise.all([
138
+ connectInitiator(phoneT, agent.publicKey),
139
+ connectResponder(agentT, agent),
140
+ ]);
141
+
142
+ const got = collect(agentCh);
143
+ let closed = false;
144
+ agentCh.onClose(() => {
145
+ closed = true;
146
+ });
147
+
148
+ phone.send(utf8('tampered payload'));
149
+ await new Promise((r) => setTimeout(r, 5));
150
+
151
+ expect(got).toEqual([]); // never delivered
152
+ expect(closed).toBe(true); // channel torn down on tamper
153
+ });
154
+ });