@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.
package/src/channel.ts ADDED
@@ -0,0 +1,124 @@
1
+ /**
2
+ * `SecureChannel` ties the handshake + framing to a concrete message transport
3
+ * (a WebSocket, or anything that delivers discrete binary messages in order).
4
+ *
5
+ * It runs the two-message handshake first, then transparently seals everything
6
+ * the caller `send()`s and opens everything that arrives, emitting plaintext via
7
+ * `onMessage`. Both ends use the same channel object; only the constructor
8
+ * (`connectInitiator` for the phone, `connectResponder` for the agent shim)
9
+ * differs. The returned promise resolves once the channel is encrypted and
10
+ * ready — any handshake failure (length, pin mismatch, bad signature, early
11
+ * close) rejects it, and the caller must abort the connection.
12
+ */
13
+ import { FrameOpener, FrameSealer } from './frame.js';
14
+ import {
15
+ finishInitiator,
16
+ respond,
17
+ startInitiator,
18
+ type SessionKeys,
19
+ } from './handshake.js';
20
+ import type { Identity } from './identity.js';
21
+
22
+ /** A minimal duplex transport of discrete, ordered binary messages. */
23
+ export interface MessageTransport {
24
+ /** Send one binary message. */
25
+ send(data: Uint8Array): void;
26
+ /** Register the (single) handler for inbound binary messages. */
27
+ onMessage(handler: (data: Uint8Array) => void): void;
28
+ /** Register the (single) handler for transport close. */
29
+ onClose(handler: () => void): void;
30
+ /** Close the underlying transport. */
31
+ close(): void;
32
+ }
33
+
34
+ export interface SecureChannel {
35
+ /** Encrypt and send one plaintext message. */
36
+ send(plaintext: Uint8Array): void;
37
+ /** Register the handler for decrypted inbound messages. Buffered until set. */
38
+ onMessage(handler: (plaintext: Uint8Array) => void): void;
39
+ /** Register a close handler (fires on transport close or fatal frame error). */
40
+ onClose(handler: () => void): void;
41
+ /** Tear down the channel and the underlying transport. */
42
+ close(): void;
43
+ }
44
+
45
+ function makeChannel(transport: MessageTransport, keys: SessionKeys): SecureChannel {
46
+ const sealer = new FrameSealer(keys.sendKey);
47
+ const opener = new FrameOpener(keys.recvKey);
48
+ let onMsg: ((pt: Uint8Array) => void) | null = null;
49
+ let onClose: (() => void) | null = null;
50
+ const backlog: Uint8Array[] = [];
51
+ let closed = false;
52
+
53
+ const close = (): void => {
54
+ if (closed) return;
55
+ closed = true;
56
+ transport.close();
57
+ onClose?.();
58
+ };
59
+
60
+ transport.onMessage((data) => {
61
+ let pt: Uint8Array;
62
+ try {
63
+ pt = opener.open(data);
64
+ } catch {
65
+ // A frame that fails to open is a tampering/replay attempt on the wire —
66
+ // tear the whole channel down rather than skipping the message.
67
+ close();
68
+ return;
69
+ }
70
+ if (onMsg) onMsg(pt);
71
+ else backlog.push(pt);
72
+ });
73
+ transport.onClose(() => {
74
+ if (closed) return;
75
+ closed = true;
76
+ onClose?.();
77
+ });
78
+
79
+ return {
80
+ send: (plaintext) => transport.send(sealer.seal(plaintext)),
81
+ onMessage: (handler) => {
82
+ onMsg = handler;
83
+ while (backlog.length > 0) handler(backlog.shift() as Uint8Array);
84
+ },
85
+ onClose: (handler) => {
86
+ onClose = handler;
87
+ },
88
+ close,
89
+ };
90
+ }
91
+
92
+ /**
93
+ * Wait for exactly the next inbound transport message (the handshake reply),
94
+ * rejecting if the transport closes first. Used only during the handshake.
95
+ */
96
+ function nextMessage(transport: MessageTransport): Promise<Uint8Array> {
97
+ return new Promise((resolve, reject) => {
98
+ transport.onMessage((data) => resolve(data));
99
+ transport.onClose(() => reject(new Error('proxy-e2e: transport closed during handshake')));
100
+ });
101
+ }
102
+
103
+ /** Phone side: pin the agent's public key (from the QR `fp`) and connect. */
104
+ export async function connectInitiator(
105
+ transport: MessageTransport,
106
+ pinnedPublicKey: Uint8Array,
107
+ ): Promise<SecureChannel> {
108
+ const { clientHello, state } = startInitiator();
109
+ const reply = nextMessage(transport);
110
+ transport.send(clientHello);
111
+ const keys = finishInitiator(await reply, state, pinnedPublicKey);
112
+ return makeChannel(transport, keys);
113
+ }
114
+
115
+ /** Agent side: respond with a signed ServerHello and connect. */
116
+ export async function connectResponder(
117
+ transport: MessageTransport,
118
+ identity: Identity,
119
+ ): Promise<SecureChannel> {
120
+ const clientHello = await nextMessage(transport);
121
+ const { serverHello, keys } = respond(clientHello, identity);
122
+ transport.send(serverHello);
123
+ return makeChannel(transport, keys);
124
+ }
@@ -0,0 +1,63 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { sha256 } from '@noble/hashes/sha2.js';
3
+ import { utf8 } from './bytes.js';
4
+ import { FrameOpener, FrameSealer } from './frame.js';
5
+
6
+ const key = (): Uint8Array => sha256(utf8('test-key'));
7
+
8
+ describe('frame', () => {
9
+ it('seals and opens a round trip', () => {
10
+ const sealer = new FrameSealer(key());
11
+ const opener = new FrameOpener(key());
12
+ const msg = utf8('hello world');
13
+ expect([...opener.open(sealer.seal(msg))]).toEqual([...msg]);
14
+ });
15
+
16
+ it('preserves ordering across many frames', () => {
17
+ const sealer = new FrameSealer(key());
18
+ const opener = new FrameOpener(key());
19
+ for (let i = 0; i < 50; i++) {
20
+ const m = utf8(`msg-${i}`);
21
+ expect([...opener.open(sealer.seal(m))]).toEqual([...m]);
22
+ }
23
+ });
24
+
25
+ it('rejects a tampered ciphertext', () => {
26
+ const sealer = new FrameSealer(key());
27
+ const opener = new FrameOpener(key());
28
+ const frame = sealer.seal(utf8('secret'));
29
+ frame[frame.length - 1] ^= 0x01;
30
+ expect(() => opener.open(frame)).toThrow();
31
+ });
32
+
33
+ it('rejects a tampered sequence number', () => {
34
+ const sealer = new FrameSealer(key());
35
+ const opener = new FrameOpener(key());
36
+ const frame = sealer.seal(utf8('secret'));
37
+ frame[0] ^= 0x01; // mutate the seq header (bound as AAD)
38
+ expect(() => opener.open(frame)).toThrow();
39
+ });
40
+
41
+ it('rejects a replayed frame', () => {
42
+ const sealer = new FrameSealer(key());
43
+ const opener = new FrameOpener(key());
44
+ const frame = sealer.seal(utf8('once'));
45
+ opener.open(frame);
46
+ expect(() => opener.open(frame)).toThrow(/replayed|out-of-order/);
47
+ });
48
+
49
+ it('rejects reordered frames', () => {
50
+ const sealer = new FrameSealer(key());
51
+ const opener = new FrameOpener(key());
52
+ const f0 = sealer.seal(utf8('zero'));
53
+ const f1 = sealer.seal(utf8('one'));
54
+ opener.open(f1); // accept seq 1 first
55
+ expect(() => opener.open(f0)).toThrow(/out-of-order/); // seq 0 now stale
56
+ });
57
+
58
+ it('rejects a frame opened with the wrong key', () => {
59
+ const sealer = new FrameSealer(key());
60
+ const opener = new FrameOpener(sha256(utf8('other-key')));
61
+ expect(() => opener.open(sealer.seal(utf8('secret')))).toThrow();
62
+ });
63
+ });
package/src/frame.ts ADDED
@@ -0,0 +1,56 @@
1
+ /**
2
+ * Per-message AEAD framing for the post-handshake channel.
3
+ *
4
+ * Each frame: seq(8 BE) ‖ nonce(24) ‖ XChaCha20-Poly1305(key, nonce, aad=seq).
5
+ *
6
+ * The sequence number is bound as AAD and enforced strictly increasing by the
7
+ * receiver, so the relay (the adversary on the wire) cannot replay, reorder, or
8
+ * drop-and-resend a frame without the open failing. XChaCha's 192-bit random
9
+ * nonce means we never have to synchronise a nonce counter across the wire.
10
+ *
11
+ * A sealer/opener pair is one-directional; a SecureChannel keeps one of each,
12
+ * keyed by the directional keys from the handshake.
13
+ */
14
+ import { xchacha20poly1305 } from '@noble/ciphers/chacha.js';
15
+ import { randomBytes } from '@noble/hashes/utils.js';
16
+ import { concatBytes, readU64be, u64be } from './bytes.js';
17
+
18
+ const SEQ = 8;
19
+ const NONCE = 24;
20
+ const HEADER = SEQ + NONCE; // 32
21
+
22
+ /** Seals outgoing plaintext into authenticated frames with a monotonic counter. */
23
+ export class FrameSealer {
24
+ private seq = 0;
25
+ constructor(private readonly key: Uint8Array) {}
26
+
27
+ seal(plaintext: Uint8Array): Uint8Array {
28
+ const seqBytes = u64be(this.seq);
29
+ const nonce = randomBytes(NONCE);
30
+ const ct = xchacha20poly1305(this.key, nonce, seqBytes).encrypt(plaintext);
31
+ this.seq += 1;
32
+ return concatBytes(seqBytes, nonce, ct);
33
+ }
34
+ }
35
+
36
+ /** Opens incoming frames, rejecting any whose sequence is not strictly increasing. */
37
+ export class FrameOpener {
38
+ /** Highest sequence accepted so far; -1 means "nothing yet". */
39
+ private lastSeq = -1;
40
+ constructor(private readonly key: Uint8Array) {}
41
+
42
+ open(frame: Uint8Array): Uint8Array {
43
+ if (frame.length < HEADER) throw new Error('proxy-e2e: frame too short');
44
+ const seqBytes = frame.slice(0, SEQ);
45
+ const seq = readU64be(seqBytes);
46
+ if (seq <= this.lastSeq) {
47
+ throw new Error(`proxy-e2e: out-of-order/replayed frame (seq ${seq} <= ${this.lastSeq})`);
48
+ }
49
+ const nonce = frame.slice(SEQ, HEADER);
50
+ const ct = frame.slice(HEADER);
51
+ // Throws if the tag (over ciphertext + seq AAD) doesn't verify.
52
+ const pt = xchacha20poly1305(this.key, nonce, seqBytes).decrypt(ct);
53
+ this.lastSeq = seq;
54
+ return pt;
55
+ }
56
+ }
@@ -0,0 +1,56 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { generateIdentity } from './identity.js';
3
+ import {
4
+ CLIENT_HELLO_LEN,
5
+ finishInitiator,
6
+ respond,
7
+ SERVER_HELLO_LEN,
8
+ startInitiator,
9
+ } from './handshake.js';
10
+
11
+ describe('handshake', () => {
12
+ it('agrees on matching directional keys', () => {
13
+ const agent = generateIdentity();
14
+ const { clientHello, state } = startInitiator();
15
+ expect(clientHello.length).toBe(CLIENT_HELLO_LEN);
16
+
17
+ const { serverHello, keys: rKeys } = respond(clientHello, agent);
18
+ expect(serverHello.length).toBe(SERVER_HELLO_LEN);
19
+
20
+ const iKeys = finishInitiator(serverHello, state, agent.publicKey);
21
+
22
+ // Initiator's send is responder's recv, and vice versa.
23
+ expect([...iKeys.sendKey]).toEqual([...rKeys.recvKey]);
24
+ expect([...iKeys.recvKey]).toEqual([...rKeys.sendKey]);
25
+ // The two directions use different keys.
26
+ expect([...iKeys.sendKey]).not.toEqual([...iKeys.recvKey]);
27
+ });
28
+
29
+ it('rejects a server identity that does not match the pin (spoofing)', () => {
30
+ const agent = generateIdentity();
31
+ const attacker = generateIdentity();
32
+ const { clientHello, state } = startInitiator();
33
+ const { serverHello } = respond(clientHello, attacker);
34
+
35
+ expect(() => finishInitiator(serverHello, state, agent.publicKey)).toThrow(/pinned fingerprint/);
36
+ });
37
+
38
+ it('rejects a tampered ServerHello signature', () => {
39
+ const agent = generateIdentity();
40
+ const { clientHello, state } = startInitiator();
41
+ const { serverHello } = respond(clientHello, agent);
42
+
43
+ // Flip a bit inside the signature region (last 64 bytes).
44
+ const tampered = serverHello.slice();
45
+ tampered[tampered.length - 1] ^= 0x01;
46
+
47
+ expect(() => finishInitiator(tampered, state, agent.publicKey)).toThrow();
48
+ });
49
+
50
+ it('rejects malformed lengths', () => {
51
+ const agent = generateIdentity();
52
+ expect(() => respond(new Uint8Array(10), agent)).toThrow(/ClientHello/);
53
+ const { state } = startInitiator();
54
+ expect(() => finishInitiator(new Uint8Array(10), state, agent.publicKey)).toThrow(/ServerHello/);
55
+ });
56
+ });
@@ -0,0 +1,136 @@
1
+ /**
2
+ * The proxy E2E handshake: an authenticated, forward-secret key agreement run
3
+ * as the first two messages inside the tunnel, before any session traffic.
4
+ *
5
+ * Shape (station-to-station-lite / signed ephemeral ECDH):
6
+ * initiator (phone) ──ClientHello──▶ responder (agent)
7
+ * ClientHello = ephI_pub(32) ‖ nC(16)
8
+ * responder ──ServerHello──▶ initiator
9
+ * ServerHello = ephR_pub(32) ‖ nS(16) ‖ idPub(32) ‖ sig(64)
10
+ * sig = Ed25519.sign(transcript, idSecret)
11
+ * transcript = LABEL ‖ ephI_pub ‖ ephR_pub ‖ nC ‖ nS
12
+ *
13
+ * The initiator pins `idPub` against the QR fingerprint (constant-time) and
14
+ * verifies `sig` over the transcript — so a relay (or anyone without the private
15
+ * key) cannot impersonate the agent. Both sides derive the session keys from the
16
+ * X25519 shared secret via HKDF; the ephemeral keys give forward secrecy. Keys
17
+ * are split per direction to prevent reflection.
18
+ *
19
+ * The phone is NOT authenticated by key here — it authenticates with the bearer
20
+ * token at the app layer (unchanged), which now travels encrypted inside this
21
+ * channel.
22
+ */
23
+ import { x25519 } from '@noble/curves/ed25519.js';
24
+ import { hkdf } from '@noble/hashes/hkdf.js';
25
+ import { sha256 } from '@noble/hashes/sha2.js';
26
+ import { randomBytes } from '@noble/hashes/utils.js';
27
+ import { concatBytes, constantTimeEqual, utf8 } from './bytes.js';
28
+ import { sign, verify } from './identity.js';
29
+
30
+ const LABEL = utf8('moxxy/proxy-e2e/v1');
31
+ const HS_NONCE = 16;
32
+ const X_PUB = 32;
33
+ const ID_PUB = 32;
34
+ const SIG = 64;
35
+
36
+ export const CLIENT_HELLO_LEN = X_PUB + HS_NONCE; // 48
37
+ export const SERVER_HELLO_LEN = X_PUB + HS_NONCE + ID_PUB + SIG; // 144
38
+
39
+ /** Directional session keys, named from the owner's point of view. */
40
+ export interface SessionKeys {
41
+ /** Key for frames this side SENDS. */
42
+ readonly sendKey: Uint8Array;
43
+ /** Key for frames this side RECEIVES. */
44
+ readonly recvKey: Uint8Array;
45
+ }
46
+
47
+ /** Opaque initiator state carried between {@link startInitiator} and {@link finishInitiator}. */
48
+ export interface InitiatorState {
49
+ readonly ephSecret: Uint8Array;
50
+ readonly ephPublic: Uint8Array;
51
+ readonly clientNonce: Uint8Array;
52
+ }
53
+
54
+ function transcript(
55
+ ephIPub: Uint8Array,
56
+ ephRPub: Uint8Array,
57
+ nC: Uint8Array,
58
+ nS: Uint8Array,
59
+ ): Uint8Array {
60
+ return concatBytes(LABEL, ephIPub, ephRPub, nC, nS);
61
+ }
62
+
63
+ function deriveKeys(shared: Uint8Array, nC: Uint8Array, nS: Uint8Array): { i2r: Uint8Array; r2i: Uint8Array } {
64
+ const okm = hkdf(sha256, shared, concatBytes(nC, nS), LABEL, 64);
65
+ return { i2r: okm.slice(0, 32), r2i: okm.slice(32, 64) };
66
+ }
67
+
68
+ /** Initiator (phone) step 1: produce the ClientHello and the state to finish later. */
69
+ export function startInitiator(): { clientHello: Uint8Array; state: InitiatorState } {
70
+ const { secretKey: ephSecret, publicKey: ephPublic } = x25519.keygen();
71
+ const clientNonce = randomBytes(HS_NONCE);
72
+ return {
73
+ clientHello: concatBytes(ephPublic, clientNonce),
74
+ state: { ephSecret, ephPublic, clientNonce },
75
+ };
76
+ }
77
+
78
+ /**
79
+ * Responder (agent) step: consume the ClientHello, produce the signed
80
+ * ServerHello, and derive the session keys. Throws on a malformed ClientHello.
81
+ */
82
+ export function respond(
83
+ clientHello: Uint8Array,
84
+ identity: { secretKey: Uint8Array; publicKey: Uint8Array },
85
+ ): { serverHello: Uint8Array; keys: SessionKeys } {
86
+ if (clientHello.length !== CLIENT_HELLO_LEN) {
87
+ throw new Error(`proxy-e2e: bad ClientHello length ${clientHello.length}`);
88
+ }
89
+ const ephIPub = clientHello.slice(0, X_PUB);
90
+ const nC = clientHello.slice(X_PUB, X_PUB + HS_NONCE);
91
+
92
+ const { secretKey: ephSecret, publicKey: ephRPub } = x25519.keygen();
93
+ const nS = randomBytes(HS_NONCE);
94
+ const sig = sign(transcript(ephIPub, ephRPub, nC, nS), identity.secretKey);
95
+
96
+ const shared = x25519.getSharedSecret(ephSecret, ephIPub);
97
+ const { i2r, r2i } = deriveKeys(shared, nC, nS);
98
+
99
+ return {
100
+ serverHello: concatBytes(ephRPub, nS, identity.publicKey, sig),
101
+ keys: { sendKey: r2i, recvKey: i2r },
102
+ };
103
+ }
104
+
105
+ /**
106
+ * Initiator (phone) step 2: consume the ServerHello, verify the agent's pinned
107
+ * identity + signature, and derive the session keys. Throws on length mismatch,
108
+ * a fingerprint that doesn't match the pin, or an invalid signature — every
109
+ * failure means "this is not the agent the QR named", so the caller must abort.
110
+ */
111
+ export function finishInitiator(
112
+ serverHello: Uint8Array,
113
+ state: InitiatorState,
114
+ pinnedPublicKey: Uint8Array,
115
+ ): SessionKeys {
116
+ if (serverHello.length !== SERVER_HELLO_LEN) {
117
+ throw new Error(`proxy-e2e: bad ServerHello length ${serverHello.length}`);
118
+ }
119
+ let off = 0;
120
+ const ephRPub = serverHello.slice(off, (off += X_PUB));
121
+ const nS = serverHello.slice(off, (off += HS_NONCE));
122
+ const idPub = serverHello.slice(off, (off += ID_PUB));
123
+ const sig = serverHello.slice(off, (off += SIG));
124
+
125
+ if (!constantTimeEqual(idPub, pinnedPublicKey)) {
126
+ throw new Error('proxy-e2e: identity key does not match pinned fingerprint (possible spoofing)');
127
+ }
128
+ const t = transcript(state.ephPublic, ephRPub, state.clientNonce, nS);
129
+ if (!verify(sig, t, idPub)) {
130
+ throw new Error('proxy-e2e: handshake signature invalid (possible spoofing)');
131
+ }
132
+
133
+ const shared = x25519.getSharedSecret(state.ephSecret, ephRPub);
134
+ const { i2r, r2i } = deriveKeys(shared, state.clientNonce, nS);
135
+ return { sendKey: i2r, recvKey: r2i };
136
+ }
@@ -0,0 +1,54 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import {
3
+ deriveUuid,
4
+ fingerprint,
5
+ generateIdentity,
6
+ publicKeyFromFingerprint,
7
+ publicKeyFromSecret,
8
+ sign,
9
+ UUID_LABEL_LENGTH,
10
+ verify,
11
+ } from './identity.js';
12
+
13
+ describe('identity', () => {
14
+ it('generates a 32-byte keypair whose public key derives from the secret', () => {
15
+ const id = generateIdentity();
16
+ expect(id.secretKey.length).toBe(32);
17
+ expect(id.publicKey.length).toBe(32);
18
+ expect([...publicKeyFromSecret(id.secretKey)]).toEqual([...id.publicKey]);
19
+ });
20
+
21
+ it('round-trips the fingerprint to the public key', () => {
22
+ const id = generateIdentity();
23
+ expect([...publicKeyFromFingerprint(fingerprint(id.publicKey))]).toEqual([...id.publicKey]);
24
+ });
25
+
26
+ it('rejects a malformed fingerprint', () => {
27
+ expect(() => publicKeyFromFingerprint('AAAA')).toThrow();
28
+ });
29
+
30
+ it('derives a stable, DNS-safe, fixed-length uuid from the public key', () => {
31
+ const id = generateIdentity();
32
+ const uuid = deriveUuid(id.publicKey);
33
+ expect(uuid).toBe(deriveUuid(id.publicKey)); // deterministic
34
+ expect(uuid.length).toBe(UUID_LABEL_LENGTH);
35
+ expect(uuid).toMatch(/^[a-z2-7]+$/);
36
+ });
37
+
38
+ it('gives distinct uuids for distinct keys', () => {
39
+ expect(deriveUuid(generateIdentity().publicKey)).not.toBe(
40
+ deriveUuid(generateIdentity().publicKey),
41
+ );
42
+ });
43
+
44
+ it('signs and verifies, and rejects tampered messages/keys', () => {
45
+ const id = generateIdentity();
46
+ const msg = new Uint8Array([1, 2, 3, 4, 5]);
47
+ const sig = sign(msg, id.secretKey);
48
+ expect(verify(sig, msg, id.publicKey)).toBe(true);
49
+
50
+ const tampered = new Uint8Array([1, 2, 3, 4, 6]);
51
+ expect(verify(sig, tampered, id.publicKey)).toBe(false);
52
+ expect(verify(sig, msg, generateIdentity().publicKey)).toBe(false);
53
+ });
54
+ });
@@ -0,0 +1,73 @@
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
+
19
+ /** Number of base32 chars in the uuid label (16 × 5 = 80 bits of preimage strength). */
20
+ export const UUID_LABEL_LENGTH = 16;
21
+
22
+ export interface Identity {
23
+ /** Ed25519 secret key (32 bytes). Never leaves the agent. */
24
+ readonly secretKey: Uint8Array;
25
+ /** Ed25519 public key (32 bytes). The identity. */
26
+ readonly publicKey: Uint8Array;
27
+ }
28
+
29
+ /** Generate a fresh agent identity. */
30
+ export function generateIdentity(): Identity {
31
+ const { secretKey, publicKey } = ed25519.keygen();
32
+ return { secretKey, publicKey };
33
+ }
34
+
35
+ /** Recover the public key from a stored secret key. */
36
+ export function publicKeyFromSecret(secretKey: Uint8Array): Uint8Array {
37
+ return ed25519.getPublicKey(secretKey);
38
+ }
39
+
40
+ /** The QR-carried fingerprint: base64url of the raw 32-byte public key. */
41
+ export function fingerprint(publicKey: Uint8Array): string {
42
+ return base64urlEncode(publicKey);
43
+ }
44
+
45
+ /** Parse a fingerprint string back to the 32-byte public key (throws if malformed). */
46
+ export function publicKeyFromFingerprint(fp: string): Uint8Array {
47
+ const key = base64urlDecode(fp);
48
+ if (key.length !== 32) throw new Error(`bad fingerprint: expected 32 bytes, got ${key.length}`);
49
+ return key;
50
+ }
51
+
52
+ /** Derive the stable uuid subdomain label from a public key. */
53
+ export function deriveUuid(publicKey: Uint8Array): string {
54
+ return base32Encode(sha256(publicKey)).slice(0, UUID_LABEL_LENGTH);
55
+ }
56
+
57
+ /** Sign a message with the identity's secret key (Ed25519). */
58
+ export function sign(message: Uint8Array, secretKey: Uint8Array): Uint8Array {
59
+ return ed25519.sign(message, secretKey);
60
+ }
61
+
62
+ /** Verify an Ed25519 signature against a public key. */
63
+ export function verify(
64
+ signature: Uint8Array,
65
+ message: Uint8Array,
66
+ publicKey: Uint8Array,
67
+ ): boolean {
68
+ try {
69
+ return ed25519.verify(signature, message, publicKey);
70
+ } catch {
71
+ return false;
72
+ }
73
+ }
package/src/index.ts ADDED
@@ -0,0 +1,46 @@
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 {
9
+ generateIdentity,
10
+ publicKeyFromSecret,
11
+ fingerprint,
12
+ publicKeyFromFingerprint,
13
+ deriveUuid,
14
+ sign,
15
+ verify,
16
+ UUID_LABEL_LENGTH,
17
+ type Identity,
18
+ } from './identity.js';
19
+
20
+ export {
21
+ startInitiator,
22
+ respond,
23
+ finishInitiator,
24
+ CLIENT_HELLO_LEN,
25
+ SERVER_HELLO_LEN,
26
+ type SessionKeys,
27
+ type InitiatorState,
28
+ } from './handshake.js';
29
+
30
+ export { FrameSealer, FrameOpener } from './frame.js';
31
+
32
+ export {
33
+ connectInitiator,
34
+ connectResponder,
35
+ type SecureChannel,
36
+ type MessageTransport,
37
+ } from './channel.js';
38
+
39
+ export {
40
+ base64urlEncode,
41
+ base64urlDecode,
42
+ base32Encode,
43
+ constantTimeEqual,
44
+ utf8,
45
+ utf8Decode,
46
+ } from './bytes.js';
@@ -0,0 +1,38 @@
1
+ import { mkdtemp, readFile, rm, stat } from 'node:fs/promises';
2
+ import { tmpdir } from 'node:os';
3
+ import { join } from 'node:path';
4
+ import { afterEach, beforeEach, describe, expect, it } from 'vitest';
5
+ import { loadOrCreateIdentity } from './node.js';
6
+
7
+ describe('loadOrCreateIdentity', () => {
8
+ let dir: string;
9
+ beforeEach(async () => {
10
+ dir = await mkdtemp(join(tmpdir(), 'proxy-id-'));
11
+ });
12
+ afterEach(async () => {
13
+ await rm(dir, { recursive: true, force: true });
14
+ });
15
+
16
+ it('creates a fresh identity, persists it 0600, and reloads the same one', async () => {
17
+ const path = join(dir, 'proxy-identity.key');
18
+ const first = await loadOrCreateIdentity(path);
19
+ expect(first.secretKey.length).toBe(32);
20
+
21
+ const mode = (await stat(path)).mode & 0o777;
22
+ expect(mode).toBe(0o600);
23
+
24
+ const second = await loadOrCreateIdentity(path);
25
+ expect([...second.secretKey]).toEqual([...first.secretKey]);
26
+ expect([...second.publicKey]).toEqual([...first.publicKey]);
27
+ });
28
+
29
+ it('regenerates if the stored key is malformed', async () => {
30
+ const path = join(dir, 'proxy-identity.key');
31
+ await loadOrCreateIdentity(path);
32
+ const { writeFile } = await import('node:fs/promises');
33
+ await writeFile(path, 'garbage', 'utf8');
34
+ const regenerated = await loadOrCreateIdentity(path);
35
+ expect(regenerated.secretKey.length).toBe(32);
36
+ expect((await readFile(path, 'utf8')).trim()).not.toBe('garbage');
37
+ });
38
+ });