@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 +57 -3
- package/package.json +4 -7
- package/src/constants.ts +2 -2
- package/src/crypto.ts +9 -9
- package/src/handshake.ts +6 -0
- package/src/session.test.ts +10 -10
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-
|
|
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
|
|
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
|
-
|
|
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.
|
|
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": "^
|
|
49
|
-
"@noble/curves": "^
|
|
50
|
-
"@noble/hashes": "^
|
|
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 =
|
|
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/
|
|
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.
|
|
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.
|
|
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(
|
package/src/session.test.ts
CHANGED
|
@@ -185,16 +185,16 @@ describe("session", () => {
|
|
|
185
185
|
const daemonSession = createDaemonSession(sessionKeys);
|
|
186
186
|
const clientSession = createClientSession(clientId, sessionKeys);
|
|
187
187
|
|
|
188
|
-
// Encrypt
|
|
188
|
+
// Encrypt 200 messages to advance the window (default window is 128)
|
|
189
189
|
const messages: EncryptedMessage[] = [];
|
|
190
|
-
for (let i = 0; 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
|
|
197
|
-
for (let 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
|
|
220
|
+
// Encrypt 200 messages to advance the window (default window is 128)
|
|
221
221
|
const messages: EncryptedMessage[] = [];
|
|
222
|
-
for (let i = 0; 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
|
|
229
|
-
for (let 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
|
|