@sideband/secure-relay 0.2.0 → 0.2.2

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 CHANGED
@@ -12,7 +12,7 @@ Implements authenticated handshake, key derivation, and message encryption for s
12
12
  - **TOFU identity pinning** — Trust-on-first-use with key change detection
13
13
  - **Replay protection** — Bitmap-based sequence window
14
14
 
15
- ## Non-Goals
15
+ ## Non-goals
16
16
 
17
17
  This package intentionally does NOT:
18
18
 
@@ -21,6 +21,23 @@ This package intentionally does NOT:
21
21
  - Persist identity keys or TOFU pins
22
22
  - Implement relay authentication or tokens
23
23
 
24
+ ## Threat model
25
+
26
+ This package protects the **payload** of messages between a browser client and a daemon via an untrusted relay. Specifically:
27
+
28
+ - The relay cannot read or tamper with message content (authenticated encryption).
29
+ - A MITM cannot impersonate the daemon without its Ed25519 private key (signature verification on handshake).
30
+ - Replayed messages are rejected within the sequence window.
31
+
32
+ It does **not** protect against:
33
+
34
+ - Compromise of the daemon's identity key (store it securely; if lost, all clients see a TOFU mismatch).
35
+ - Traffic analysis (message sizes and timing are visible to the relay).
36
+ - Key storage security — this package has no opinion on where keys live; that's the caller's responsibility.
37
+ - Denial of service from a malicious relay (the relay can drop or delay messages).
38
+
39
+ This implementation has not undergone a formal third-party security audit. Use accordingly.
40
+
24
41
  ## Install
25
42
 
26
43
  ```bash
@@ -78,7 +95,7 @@ const encrypted = encryptClientToDaemon(daemonSession, plaintext);
78
95
  const decrypted = decryptClientToDaemon(clientSession, encrypted);
79
96
  ```
80
97
 
81
- ## TOFU Security
98
+ ## TOFU security
82
99
 
83
100
  Identity keys use trust-on-first-use (TOFU) pinning:
84
101
 
@@ -86,7 +103,44 @@ Identity keys use trust-on-first-use (TOFU) pinning:
86
103
  - Never accept key changes silently — `identity_key_changed` indicates potential MITM
87
104
  - On mismatch, present both fingerprints and require explicit user approval
88
105
 
89
- ## Error Handling
106
+ ### Detecting identity key changes
107
+
108
+ Compare the daemon's current identity key against your stored pin before handshake:
109
+
110
+ ```ts
111
+ import {
112
+ processHandshakeAccept,
113
+ computeFingerprint,
114
+ SbrpError,
115
+ SbrpErrorCode,
116
+ } from "@sideband/secure-relay";
117
+
118
+ // Load pinned key from storage (null on first connection)
119
+ const pinnedKey = await storage.get(`tofu:${daemonId}`);
120
+
121
+ if (pinnedKey && !equalBytes(pinnedKey, currentIdentityKey)) {
122
+ // Key changed — potential MITM attack
123
+ throw new SbrpError(
124
+ SbrpErrorCode.IdentityKeyChanged,
125
+ `Identity key changed for ${daemonId}. ` +
126
+ `Expected: ${computeFingerprint(pinnedKey)}, ` +
127
+ `Got: ${computeFingerprint(currentIdentityKey)}`,
128
+ );
129
+ }
130
+
131
+ // First connection: pin the key after successful handshake
132
+ const result = processHandshakeAccept(
133
+ accept,
134
+ daemonId,
135
+ currentIdentityKey,
136
+ ephemeralKeyPair,
137
+ );
138
+ if (!pinnedKey) {
139
+ await storage.set(`tofu:${daemonId}`, currentIdentityKey);
140
+ }
141
+ ```
142
+
143
+ ## Error handling
90
144
 
91
145
  All errors throw `SbrpError` with a specific `code`:
92
146
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sideband/secure-relay",
3
- "version": "0.2.0",
3
+ "version": "0.2.2",
4
4
  "description": "Secure Relay Protocol (SBRP): E2EE handshake, session encryption, and TOFU identity pinning for relay-mediated communication.",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {
@@ -45,12 +45,9 @@
45
45
  "./package.json": "./package.json"
46
46
  },
47
47
  "dependencies": {
48
- "@noble/ciphers": "^1.2.1",
49
- "@noble/curves": "^1.8.1",
50
- "@noble/hashes": "^1.7.1"
51
- },
52
- "devDependencies": {
53
- "@types/bun": "^1.3.3"
48
+ "@noble/ciphers": "^2.1.1",
49
+ "@noble/curves": "^2.0.1",
50
+ "@noble/hashes": "^2.0.1"
54
51
  },
55
52
  "files": [
56
53
  "dist",
package/src/constants.ts CHANGED
@@ -40,8 +40,8 @@ export const NONCE_LENGTH = 12;
40
40
  /** Length of Poly1305 auth tag in bytes */
41
41
  export const AUTH_TAG_LENGTH = 16;
42
42
 
43
- /** Default replay window size (bits) */
44
- export const DEFAULT_REPLAY_WINDOW_SIZE = 64n;
43
+ /** Default replay window size (bits). Spec requires ≥64, recommends ≥128. */
44
+ export const DEFAULT_REPLAY_WINDOW_SIZE = 128n;
45
45
 
46
46
  /** Direction bytes in nonce (4 bytes, big-endian) */
47
47
  export const DIRECTION_CLIENT_TO_DAEMON = new Uint8Array([0, 0, 0, 1]);
package/src/crypto.ts CHANGED
@@ -7,12 +7,12 @@
7
7
  * and @noble/hashes for SHA-256/HKDF.
8
8
  */
9
9
 
10
- import { chacha20poly1305 } from "@noble/ciphers/chacha";
11
- import { ed25519 } from "@noble/curves/ed25519";
12
- import { x25519 } from "@noble/curves/ed25519";
13
- import { hkdf } from "@noble/hashes/hkdf";
14
- import { sha256 } from "@noble/hashes/sha256";
15
- import { concatBytes, randomBytes } from "@noble/hashes/utils";
10
+ import { chacha20poly1305 } from "@noble/ciphers/chacha.js";
11
+ import { ed25519 } from "@noble/curves/ed25519.js";
12
+ import { x25519 } from "@noble/curves/ed25519.js";
13
+ import { hkdf } from "@noble/hashes/hkdf.js";
14
+ import { sha256 } from "@noble/hashes/sha2.js";
15
+ import { concatBytes, randomBytes } from "@noble/hashes/utils.js";
16
16
  import {
17
17
  AUTH_TAG_LENGTH,
18
18
  DIRECTION_CLIENT_TO_DAEMON,
@@ -36,14 +36,14 @@ const textEncoder = new TextEncoder();
36
36
 
37
37
  /** Generate a new Ed25519 identity keypair */
38
38
  export function generateIdentityKeyPair(): IdentityKeyPair {
39
- const privateKey = ed25519.utils.randomPrivateKey();
39
+ const privateKey = ed25519.utils.randomSecretKey();
40
40
  const publicKey = ed25519.getPublicKey(privateKey);
41
41
  return { publicKey, privateKey };
42
42
  }
43
43
 
44
44
  /** Generate a new X25519 ephemeral keypair */
45
45
  export function generateEphemeralKeyPair(): EphemeralKeyPair {
46
- const privateKey = x25519.utils.randomPrivateKey();
46
+ const privateKey = x25519.utils.randomSecretKey();
47
47
  const publicKey = x25519.getPublicKey(privateKey);
48
48
  return { publicKey, privateKey };
49
49
  }
@@ -137,7 +137,7 @@ export function deriveSessionKeys(
137
137
  sha256,
138
138
  sharedSecret,
139
139
  transcriptHash,
140
- SBRP_SESSION_KEYS_INFO,
140
+ textEncoder.encode(SBRP_SESSION_KEYS_INFO),
141
141
  SESSION_KEYS_LENGTH,
142
142
  );
143
143
 
package/src/handshake.ts CHANGED
@@ -65,6 +65,9 @@ export function createHandshakeInit(): {
65
65
  * 1. Generate ephemeral X25519 keypair
66
66
  * 2. Sign ephemeral public key with identity key (context-bound)
67
67
  * 3. Derive session keys
68
+ *
69
+ * NOTE: Callers MUST enforce a 30-second handshake timeout per SBRP §1.4.
70
+ * This function does not track time; timeout enforcement is a transport concern.
68
71
  */
69
72
  export function processHandshakeInit(
70
73
  init: HandshakeInit,
@@ -117,6 +120,9 @@ export function processHandshakeInit(
117
120
  * 1. Verify signature using PINNED identity key (TOFU)
118
121
  * 2. Derive session keys using same transcript hash as daemon
119
122
  *
123
+ * NOTE: Callers MUST enforce a 30-second handshake timeout per SBRP §1.4.
124
+ * This function does not track time; timeout enforcement is a transport concern.
125
+ *
120
126
  * @throws {SbrpError} with code HandshakeFailed if signature verification fails
121
127
  */
122
128
  export function processHandshakeAccept(
@@ -185,16 +185,16 @@ describe("session", () => {
185
185
  const daemonSession = createDaemonSession(sessionKeys);
186
186
  const clientSession = createClientSession(clientId, sessionKeys);
187
187
 
188
- // Encrypt 100 messages to advance the window
188
+ // Encrypt 200 messages to advance the window (default window is 128)
189
189
  const messages: EncryptedMessage[] = [];
190
- for (let i = 0; i < 100; i++) {
190
+ for (let i = 0; i < 200; i++) {
191
191
  messages.push(
192
- encryptClientToDaemon(daemonSession, new Uint8Array([i])),
192
+ encryptClientToDaemon(daemonSession, new Uint8Array([i % 256])),
193
193
  );
194
194
  }
195
195
 
196
- // Decrypt latest 64 (within window)
197
- for (let i = 99; i >= 36; i--) {
196
+ // Decrypt latest 128 (within window)
197
+ for (let i = 199; i >= 72; i--) {
198
198
  decryptClientToDaemon(clientSession, messages[i]);
199
199
  }
200
200
 
@@ -217,16 +217,16 @@ describe("session", () => {
217
217
  const daemonSession = createDaemonSession(sessionKeys);
218
218
  const clientSession = createClientSession(clientId, sessionKeys);
219
219
 
220
- // Encrypt 100 messages to advance the window
220
+ // Encrypt 200 messages to advance the window (default window is 128)
221
221
  const messages: EncryptedMessage[] = [];
222
- for (let i = 0; i < 100; i++) {
222
+ for (let i = 0; i < 200; i++) {
223
223
  messages.push(
224
- encryptDaemonToClient(clientSession, new Uint8Array([i])),
224
+ encryptDaemonToClient(clientSession, new Uint8Array([i % 256])),
225
225
  );
226
226
  }
227
227
 
228
- // Decrypt latest 64 (within window)
229
- for (let i = 99; i >= 36; i--) {
228
+ // Decrypt latest 128 (within window)
229
+ for (let i = 199; i >= 72; i--) {
230
230
  decryptDaemonToClient(daemonSession, messages[i]);
231
231
  }
232
232