@sideband/secure-relay 0.0.1
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/LICENSE +190 -0
- package/README.md +86 -0
- package/dist/.tsbuildinfo +1 -0
- package/dist/constants.d.ts +33 -0
- package/dist/constants.d.ts.map +1 -0
- package/dist/constants.js +35 -0
- package/dist/constants.js.map +1 -0
- package/dist/crypto.d.ts +70 -0
- package/dist/crypto.d.ts.map +1 -0
- package/dist/crypto.js +145 -0
- package/dist/crypto.js.map +1 -0
- package/dist/handshake.d.ts +42 -0
- package/dist/handshake.d.ts.map +1 -0
- package/dist/handshake.js +83 -0
- package/dist/handshake.js.map +1 -0
- package/dist/index.d.ts +45 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +11 -0
- package/dist/index.js.map +1 -0
- package/dist/replay.d.ts +32 -0
- package/dist/replay.d.ts.map +1 -0
- package/dist/replay.js +89 -0
- package/dist/replay.js.map +1 -0
- package/dist/session.d.ts +67 -0
- package/dist/session.d.ts.map +1 -0
- package/dist/session.js +123 -0
- package/dist/session.js.map +1 -0
- package/dist/types.d.ts +79 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +39 -0
- package/dist/types.js.map +1 -0
- package/package.json +63 -0
- package/src/constants.ts +49 -0
- package/src/crypto.ts +234 -0
- package/src/handshake.ts +169 -0
- package/src/index.ts +132 -0
- package/src/replay.ts +115 -0
- package/src/session.ts +196 -0
- package/src/types.ts +104 -0
package/src/handshake.ts
ADDED
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2025-present Sideband
|
|
2
|
+
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* E2EE handshake protocol for Sideband Relay Protocol (SBRP).
|
|
6
|
+
*
|
|
7
|
+
* Implements the authenticated key exchange between client and daemon,
|
|
8
|
+
* with Ed25519 signatures for MITM protection.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import {
|
|
12
|
+
computeSharedSecret,
|
|
13
|
+
createSignaturePayload,
|
|
14
|
+
createTranscriptHash,
|
|
15
|
+
deriveSessionKeys,
|
|
16
|
+
generateEphemeralKeyPair,
|
|
17
|
+
signPayload,
|
|
18
|
+
verifySignature,
|
|
19
|
+
zeroize,
|
|
20
|
+
} from "./crypto.js";
|
|
21
|
+
import type {
|
|
22
|
+
DaemonId,
|
|
23
|
+
EphemeralKeyPair,
|
|
24
|
+
HandshakeAccept,
|
|
25
|
+
HandshakeInit,
|
|
26
|
+
IdentityKeyPair,
|
|
27
|
+
SessionKeys,
|
|
28
|
+
} from "./types.js";
|
|
29
|
+
import { SbrpError, SbrpErrorCode } from "./types.js";
|
|
30
|
+
|
|
31
|
+
/** Result of a successful daemon handshake */
|
|
32
|
+
export interface DaemonHandshakeResult {
|
|
33
|
+
sessionKeys: SessionKeys;
|
|
34
|
+
ephemeralKeyPair: EphemeralKeyPair;
|
|
35
|
+
signature: Uint8Array;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Result of a successful client handshake */
|
|
39
|
+
export interface ClientHandshakeResult {
|
|
40
|
+
sessionKeys: SessionKeys;
|
|
41
|
+
ephemeralKeyPair: EphemeralKeyPair;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Create a handshake init message (client side).
|
|
46
|
+
*
|
|
47
|
+
* Generates an ephemeral X25519 keypair for this session.
|
|
48
|
+
*/
|
|
49
|
+
export function createHandshakeInit(): {
|
|
50
|
+
message: HandshakeInit;
|
|
51
|
+
ephemeralKeyPair: EphemeralKeyPair;
|
|
52
|
+
} {
|
|
53
|
+
const ephemeralKeyPair = generateEphemeralKeyPair();
|
|
54
|
+
return {
|
|
55
|
+
message: {
|
|
56
|
+
type: "handshake.init",
|
|
57
|
+
initPublicKey: ephemeralKeyPair.publicKey,
|
|
58
|
+
},
|
|
59
|
+
ephemeralKeyPair,
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Process handshake init and create accept message (daemon side).
|
|
65
|
+
*
|
|
66
|
+
* 1. Generate ephemeral X25519 keypair
|
|
67
|
+
* 2. Sign ephemeral public key with identity key (context-bound)
|
|
68
|
+
* 3. Derive session keys
|
|
69
|
+
*/
|
|
70
|
+
export function processHandshakeInit(
|
|
71
|
+
init: HandshakeInit,
|
|
72
|
+
daemonId: DaemonId,
|
|
73
|
+
identityKeyPair: IdentityKeyPair,
|
|
74
|
+
): { message: HandshakeAccept; result: DaemonHandshakeResult } {
|
|
75
|
+
const ephemeralKeyPair = generateEphemeralKeyPair();
|
|
76
|
+
|
|
77
|
+
// Sign ephemeral key with context binding
|
|
78
|
+
const signaturePayload = createSignaturePayload(
|
|
79
|
+
daemonId,
|
|
80
|
+
init.initPublicKey,
|
|
81
|
+
ephemeralKeyPair.publicKey,
|
|
82
|
+
);
|
|
83
|
+
const signature = signPayload(signaturePayload, identityKeyPair.privateKey);
|
|
84
|
+
|
|
85
|
+
// Derive session keys
|
|
86
|
+
const sharedSecret = computeSharedSecret(
|
|
87
|
+
ephemeralKeyPair.privateKey,
|
|
88
|
+
init.initPublicKey,
|
|
89
|
+
);
|
|
90
|
+
const transcriptHash = createTranscriptHash(
|
|
91
|
+
daemonId,
|
|
92
|
+
init.initPublicKey,
|
|
93
|
+
ephemeralKeyPair.publicKey,
|
|
94
|
+
signature,
|
|
95
|
+
);
|
|
96
|
+
const sessionKeys = deriveSessionKeys(sharedSecret, transcriptHash);
|
|
97
|
+
|
|
98
|
+
// Best-effort zeroize shared secret
|
|
99
|
+
zeroize(sharedSecret);
|
|
100
|
+
|
|
101
|
+
return {
|
|
102
|
+
message: {
|
|
103
|
+
type: "handshake.accept",
|
|
104
|
+
acceptPublicKey: ephemeralKeyPair.publicKey,
|
|
105
|
+
signature,
|
|
106
|
+
},
|
|
107
|
+
result: {
|
|
108
|
+
sessionKeys,
|
|
109
|
+
ephemeralKeyPair,
|
|
110
|
+
signature,
|
|
111
|
+
},
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Process handshake accept message (client side).
|
|
117
|
+
*
|
|
118
|
+
* 1. Verify signature using PINNED identity key (TOFU)
|
|
119
|
+
* 2. Derive session keys using same transcript hash as daemon
|
|
120
|
+
*
|
|
121
|
+
* @throws {SbrpError} with code HandshakeFailed if signature verification fails
|
|
122
|
+
*/
|
|
123
|
+
export function processHandshakeAccept(
|
|
124
|
+
accept: HandshakeAccept,
|
|
125
|
+
daemonId: DaemonId,
|
|
126
|
+
pinnedIdentityPublicKey: Uint8Array,
|
|
127
|
+
ephemeralKeyPair: EphemeralKeyPair,
|
|
128
|
+
): ClientHandshakeResult {
|
|
129
|
+
// Verify daemon signature using PINNED key (not relay-provided!)
|
|
130
|
+
const signaturePayload = createSignaturePayload(
|
|
131
|
+
daemonId,
|
|
132
|
+
ephemeralKeyPair.publicKey,
|
|
133
|
+
accept.acceptPublicKey,
|
|
134
|
+
);
|
|
135
|
+
|
|
136
|
+
const valid = verifySignature(
|
|
137
|
+
signaturePayload,
|
|
138
|
+
accept.signature,
|
|
139
|
+
pinnedIdentityPublicKey,
|
|
140
|
+
);
|
|
141
|
+
|
|
142
|
+
if (!valid) {
|
|
143
|
+
throw new SbrpError(
|
|
144
|
+
SbrpErrorCode.HandshakeFailed,
|
|
145
|
+
"Signature verification failed",
|
|
146
|
+
);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// Derive session keys using same transcript hash as daemon
|
|
150
|
+
const sharedSecret = computeSharedSecret(
|
|
151
|
+
ephemeralKeyPair.privateKey,
|
|
152
|
+
accept.acceptPublicKey,
|
|
153
|
+
);
|
|
154
|
+
const transcriptHash = createTranscriptHash(
|
|
155
|
+
daemonId,
|
|
156
|
+
ephemeralKeyPair.publicKey,
|
|
157
|
+
accept.acceptPublicKey,
|
|
158
|
+
accept.signature,
|
|
159
|
+
);
|
|
160
|
+
const sessionKeys = deriveSessionKeys(sharedSecret, transcriptHash);
|
|
161
|
+
|
|
162
|
+
// Best-effort zeroize shared secret
|
|
163
|
+
zeroize(sharedSecret);
|
|
164
|
+
|
|
165
|
+
return {
|
|
166
|
+
sessionKeys,
|
|
167
|
+
ephemeralKeyPair,
|
|
168
|
+
};
|
|
169
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2025-present Sideband
|
|
2
|
+
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* @sideband/secure-relay
|
|
6
|
+
*
|
|
7
|
+
* Sideband Relay Protocol (SBRP) implementation for E2EE communication
|
|
8
|
+
* between daemons and clients via a relay server.
|
|
9
|
+
*
|
|
10
|
+
* Features:
|
|
11
|
+
* - Ed25519 identity signatures for MITM protection
|
|
12
|
+
* - X25519 ephemeral key exchange for forward secrecy
|
|
13
|
+
* - ChaCha20-Poly1305 authenticated encryption
|
|
14
|
+
* - TOFU (Trust On First Use) identity pinning
|
|
15
|
+
* - Bitmap-based replay protection
|
|
16
|
+
*
|
|
17
|
+
* @example
|
|
18
|
+
* ```typescript
|
|
19
|
+
* // Daemon side: generate identity keypair on first run
|
|
20
|
+
* const identity = generateIdentityKeyPair();
|
|
21
|
+
*
|
|
22
|
+
* // Client side: create handshake init
|
|
23
|
+
* const { message: init, ephemeralKeyPair } = createHandshakeInit();
|
|
24
|
+
*
|
|
25
|
+
* // Daemon side: process init and create accept
|
|
26
|
+
* const { message: accept, result } = processHandshakeInit(init, daemonId, identity);
|
|
27
|
+
* const clientSession = createClientSession(clientId, result.sessionKeys);
|
|
28
|
+
*
|
|
29
|
+
* // Client side: process accept (with TOFU-pinned identity)
|
|
30
|
+
* const { sessionKeys } = processHandshakeAccept(accept, daemonId, pinnedKey, ephemeralKeyPair);
|
|
31
|
+
* const daemonSession = createDaemonSession(sessionKeys);
|
|
32
|
+
*
|
|
33
|
+
* // Encrypt/decrypt messages
|
|
34
|
+
* const encrypted = encryptClientToDaemon(daemonSession, plaintext);
|
|
35
|
+
* const decrypted = decryptClientToDaemon(clientSession, encrypted);
|
|
36
|
+
* ```
|
|
37
|
+
*/
|
|
38
|
+
|
|
39
|
+
// Types
|
|
40
|
+
export type {
|
|
41
|
+
ClientId,
|
|
42
|
+
DaemonId,
|
|
43
|
+
EncryptedMessage,
|
|
44
|
+
EphemeralKeyPair,
|
|
45
|
+
HandshakeAccept,
|
|
46
|
+
HandshakeInit,
|
|
47
|
+
IdentityKeyPair,
|
|
48
|
+
PinnedIdentity,
|
|
49
|
+
SessionKeys,
|
|
50
|
+
} from "./types.js";
|
|
51
|
+
|
|
52
|
+
export {
|
|
53
|
+
asClientId,
|
|
54
|
+
asDaemonId,
|
|
55
|
+
Direction,
|
|
56
|
+
SbrpError,
|
|
57
|
+
SbrpErrorCode,
|
|
58
|
+
} from "./types.js";
|
|
59
|
+
|
|
60
|
+
// Constants
|
|
61
|
+
export {
|
|
62
|
+
AUTH_TAG_LENGTH,
|
|
63
|
+
DEFAULT_REPLAY_WINDOW_SIZE,
|
|
64
|
+
DIRECTION_CLIENT_TO_DAEMON,
|
|
65
|
+
DIRECTION_DAEMON_TO_CLIENT,
|
|
66
|
+
ED25519_PRIVATE_KEY_LENGTH,
|
|
67
|
+
ED25519_PUBLIC_KEY_LENGTH,
|
|
68
|
+
ED25519_SIGNATURE_LENGTH,
|
|
69
|
+
NONCE_LENGTH,
|
|
70
|
+
SBRP_HANDSHAKE_CONTEXT,
|
|
71
|
+
SBRP_SESSION_KEYS_INFO,
|
|
72
|
+
SBRP_TRANSCRIPT_CONTEXT,
|
|
73
|
+
SESSION_KEYS_LENGTH,
|
|
74
|
+
SYMMETRIC_KEY_LENGTH,
|
|
75
|
+
X25519_PRIVATE_KEY_LENGTH,
|
|
76
|
+
X25519_PUBLIC_KEY_LENGTH,
|
|
77
|
+
} from "./constants.js";
|
|
78
|
+
|
|
79
|
+
// Crypto primitives
|
|
80
|
+
export {
|
|
81
|
+
computeFingerprint,
|
|
82
|
+
computeSharedSecret,
|
|
83
|
+
constructNonce,
|
|
84
|
+
createSignaturePayload,
|
|
85
|
+
createTranscriptHash,
|
|
86
|
+
decrypt,
|
|
87
|
+
deriveSessionKeys,
|
|
88
|
+
encrypt,
|
|
89
|
+
extractSequence,
|
|
90
|
+
generateEphemeralKeyPair,
|
|
91
|
+
generateIdentityKeyPair,
|
|
92
|
+
randomBytes,
|
|
93
|
+
signPayload,
|
|
94
|
+
verifySignature,
|
|
95
|
+
zeroize,
|
|
96
|
+
} from "./crypto.js";
|
|
97
|
+
|
|
98
|
+
// Handshake
|
|
99
|
+
export type {
|
|
100
|
+
ClientHandshakeResult,
|
|
101
|
+
DaemonHandshakeResult,
|
|
102
|
+
} from "./handshake.js";
|
|
103
|
+
|
|
104
|
+
export {
|
|
105
|
+
createHandshakeInit,
|
|
106
|
+
processHandshakeAccept,
|
|
107
|
+
processHandshakeInit,
|
|
108
|
+
} from "./handshake.js";
|
|
109
|
+
|
|
110
|
+
// Replay protection
|
|
111
|
+
export type { ReplayWindow } from "./replay.js";
|
|
112
|
+
|
|
113
|
+
export {
|
|
114
|
+
checkAndUpdateReplay,
|
|
115
|
+
createReplayWindow,
|
|
116
|
+
isValidSequence,
|
|
117
|
+
resetReplayWindow,
|
|
118
|
+
} from "./replay.js";
|
|
119
|
+
|
|
120
|
+
// Session management
|
|
121
|
+
export type { ClientSession, DaemonSession } from "./session.js";
|
|
122
|
+
|
|
123
|
+
export {
|
|
124
|
+
clearClientSession,
|
|
125
|
+
clearDaemonSession,
|
|
126
|
+
createClientSession,
|
|
127
|
+
createDaemonSession,
|
|
128
|
+
decryptClientToDaemon,
|
|
129
|
+
decryptDaemonToClient,
|
|
130
|
+
encryptClientToDaemon,
|
|
131
|
+
encryptDaemonToClient,
|
|
132
|
+
} from "./session.js";
|
package/src/replay.ts
ADDED
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2025-present Sideband
|
|
2
|
+
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Bitmap-based replay protection for Sideband Relay Protocol (SBRP).
|
|
6
|
+
*
|
|
7
|
+
* Uses a sliding window to track seen sequence numbers and prevent
|
|
8
|
+
* replay attacks while avoiding memory exhaustion from attacker-controlled
|
|
9
|
+
* sequence numbers.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { DEFAULT_REPLAY_WINDOW_SIZE } from "./constants.js";
|
|
13
|
+
|
|
14
|
+
/** Replay window state */
|
|
15
|
+
export interface ReplayWindow {
|
|
16
|
+
/** Highest accepted sequence number */
|
|
17
|
+
maxSeen: bigint;
|
|
18
|
+
/** Bitmap: bit i set = sequence (maxSeen - i) was seen */
|
|
19
|
+
bitmap: bigint;
|
|
20
|
+
/** Window size in bits */
|
|
21
|
+
windowSize: bigint;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Create a new replay window.
|
|
26
|
+
*
|
|
27
|
+
* @param windowSize - Window size in bits (default: 64)
|
|
28
|
+
*/
|
|
29
|
+
export function createReplayWindow(
|
|
30
|
+
windowSize: bigint = DEFAULT_REPLAY_WINDOW_SIZE,
|
|
31
|
+
): ReplayWindow {
|
|
32
|
+
return {
|
|
33
|
+
maxSeen: -1n, // No messages seen yet
|
|
34
|
+
bitmap: 0n,
|
|
35
|
+
windowSize,
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Check if a sequence number is valid (not a replay) and update the window.
|
|
41
|
+
*
|
|
42
|
+
* @returns true if the sequence is valid and accepted, false if it's a replay
|
|
43
|
+
*/
|
|
44
|
+
export function checkAndUpdateReplay(
|
|
45
|
+
seq: bigint,
|
|
46
|
+
window: ReplayWindow,
|
|
47
|
+
): boolean {
|
|
48
|
+
// First message ever
|
|
49
|
+
if (window.maxSeen === -1n) {
|
|
50
|
+
window.maxSeen = seq;
|
|
51
|
+
window.bitmap = 1n;
|
|
52
|
+
return true;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
if (seq > window.maxSeen) {
|
|
56
|
+
// New high sequence - shift window
|
|
57
|
+
const shift = seq - window.maxSeen;
|
|
58
|
+
if (shift >= window.windowSize) {
|
|
59
|
+
// Sequence is far ahead, reset bitmap
|
|
60
|
+
window.bitmap = 1n;
|
|
61
|
+
} else {
|
|
62
|
+
window.bitmap = (window.bitmap << shift) | 1n;
|
|
63
|
+
}
|
|
64
|
+
window.maxSeen = seq;
|
|
65
|
+
return true;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Sequence is within or before the window
|
|
69
|
+
const diff = window.maxSeen - seq;
|
|
70
|
+
if (diff >= window.windowSize) {
|
|
71
|
+
// Too old, outside window
|
|
72
|
+
return false;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const mask = 1n << diff;
|
|
76
|
+
if (window.bitmap & mask) {
|
|
77
|
+
// Already seen (replay)
|
|
78
|
+
return false;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// Mark as seen
|
|
82
|
+
window.bitmap |= mask;
|
|
83
|
+
return true;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Check if a sequence number would be valid without updating the window.
|
|
88
|
+
*
|
|
89
|
+
* Useful for pre-validation before decryption.
|
|
90
|
+
*/
|
|
91
|
+
export function isValidSequence(seq: bigint, window: ReplayWindow): boolean {
|
|
92
|
+
if (window.maxSeen === -1n) {
|
|
93
|
+
return true;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
if (seq > window.maxSeen) {
|
|
97
|
+
return true;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const diff = window.maxSeen - seq;
|
|
101
|
+
if (diff >= window.windowSize) {
|
|
102
|
+
return false;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const mask = 1n << diff;
|
|
106
|
+
return (window.bitmap & mask) === 0n;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Reset the replay window to initial state.
|
|
111
|
+
*/
|
|
112
|
+
export function resetReplayWindow(window: ReplayWindow): void {
|
|
113
|
+
window.maxSeen = -1n;
|
|
114
|
+
window.bitmap = 0n;
|
|
115
|
+
}
|
package/src/session.ts
ADDED
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2025-present Sideband
|
|
2
|
+
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Session management for Sideband Relay Protocol (SBRP).
|
|
6
|
+
*
|
|
7
|
+
* Handles encrypted message sending/receiving with proper key selection,
|
|
8
|
+
* sequence number tracking, and replay protection.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { decrypt, encrypt, extractSequence, zeroize } from "./crypto.js";
|
|
12
|
+
import {
|
|
13
|
+
checkAndUpdateReplay,
|
|
14
|
+
createReplayWindow,
|
|
15
|
+
type ReplayWindow,
|
|
16
|
+
} from "./replay.js";
|
|
17
|
+
import type { ClientId, EncryptedMessage, SessionKeys } from "./types.js";
|
|
18
|
+
import { Direction, SbrpError, SbrpErrorCode } from "./types.js";
|
|
19
|
+
|
|
20
|
+
/** Crypto state for one direction of communication (traffic key, counters, replay) */
|
|
21
|
+
interface ChannelState {
|
|
22
|
+
trafficKey: Uint8Array;
|
|
23
|
+
sendSeq: bigint;
|
|
24
|
+
recvWindow: ReplayWindow;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Client session (daemon-side state for each connected client) */
|
|
28
|
+
export interface ClientSession {
|
|
29
|
+
clientId: ClientId;
|
|
30
|
+
clientToDaemon: ChannelState;
|
|
31
|
+
daemonToClient: ChannelState;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Daemon session (client-side state for communicating with daemon) */
|
|
35
|
+
export interface DaemonSession {
|
|
36
|
+
clientToDaemon: ChannelState;
|
|
37
|
+
daemonToClient: ChannelState;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Create a client session (daemon side).
|
|
42
|
+
*
|
|
43
|
+
* Used by daemon to manage state for each connected client.
|
|
44
|
+
*/
|
|
45
|
+
export function createClientSession(
|
|
46
|
+
clientId: ClientId,
|
|
47
|
+
sessionKeys: SessionKeys,
|
|
48
|
+
): ClientSession {
|
|
49
|
+
return {
|
|
50
|
+
clientId,
|
|
51
|
+
clientToDaemon: {
|
|
52
|
+
trafficKey: sessionKeys.clientToDaemon,
|
|
53
|
+
sendSeq: 0n,
|
|
54
|
+
recvWindow: createReplayWindow(),
|
|
55
|
+
},
|
|
56
|
+
daemonToClient: {
|
|
57
|
+
trafficKey: sessionKeys.daemonToClient,
|
|
58
|
+
sendSeq: 0n,
|
|
59
|
+
recvWindow: createReplayWindow(),
|
|
60
|
+
},
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Create a daemon session (client side).
|
|
66
|
+
*
|
|
67
|
+
* Used by client to communicate with daemon.
|
|
68
|
+
*/
|
|
69
|
+
export function createDaemonSession(sessionKeys: SessionKeys): DaemonSession {
|
|
70
|
+
return {
|
|
71
|
+
clientToDaemon: {
|
|
72
|
+
trafficKey: sessionKeys.clientToDaemon,
|
|
73
|
+
sendSeq: 0n,
|
|
74
|
+
recvWindow: createReplayWindow(),
|
|
75
|
+
},
|
|
76
|
+
daemonToClient: {
|
|
77
|
+
trafficKey: sessionKeys.daemonToClient,
|
|
78
|
+
sendSeq: 0n,
|
|
79
|
+
recvWindow: createReplayWindow(),
|
|
80
|
+
},
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Encrypt a message from client to daemon.
|
|
86
|
+
*/
|
|
87
|
+
export function encryptClientToDaemon(
|
|
88
|
+
session: DaemonSession,
|
|
89
|
+
plaintext: Uint8Array,
|
|
90
|
+
): EncryptedMessage {
|
|
91
|
+
const seq = session.clientToDaemon.sendSeq++;
|
|
92
|
+
const data = encrypt(
|
|
93
|
+
session.clientToDaemon.trafficKey,
|
|
94
|
+
Direction.ClientToDaemon,
|
|
95
|
+
seq,
|
|
96
|
+
plaintext,
|
|
97
|
+
);
|
|
98
|
+
|
|
99
|
+
return { type: "encrypted", seq, data };
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Encrypt a message from daemon to client.
|
|
104
|
+
*/
|
|
105
|
+
export function encryptDaemonToClient(
|
|
106
|
+
session: ClientSession,
|
|
107
|
+
plaintext: Uint8Array,
|
|
108
|
+
): EncryptedMessage {
|
|
109
|
+
const seq = session.daemonToClient.sendSeq++;
|
|
110
|
+
const data = encrypt(
|
|
111
|
+
session.daemonToClient.trafficKey,
|
|
112
|
+
Direction.DaemonToClient,
|
|
113
|
+
seq,
|
|
114
|
+
plaintext,
|
|
115
|
+
);
|
|
116
|
+
|
|
117
|
+
return { type: "encrypted", seq, data };
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Decrypt a message received by daemon from client.
|
|
122
|
+
*
|
|
123
|
+
* @throws {SbrpError} with code SequenceError if replay detected
|
|
124
|
+
* @throws {SbrpError} with code DecryptFailed if decryption fails
|
|
125
|
+
*/
|
|
126
|
+
export function decryptClientToDaemon(
|
|
127
|
+
session: ClientSession,
|
|
128
|
+
message: EncryptedMessage,
|
|
129
|
+
): Uint8Array {
|
|
130
|
+
// Check replay protection
|
|
131
|
+
const seq = extractSequence(message.data);
|
|
132
|
+
if (!checkAndUpdateReplay(seq, session.clientToDaemon.recvWindow)) {
|
|
133
|
+
throw new SbrpError(
|
|
134
|
+
SbrpErrorCode.SequenceError,
|
|
135
|
+
"Sequence number outside valid window or replay detected",
|
|
136
|
+
);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
try {
|
|
140
|
+
return decrypt(session.clientToDaemon.trafficKey, message.data);
|
|
141
|
+
} catch {
|
|
142
|
+
throw new SbrpError(
|
|
143
|
+
SbrpErrorCode.DecryptFailed,
|
|
144
|
+
"Message decryption failed",
|
|
145
|
+
);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Decrypt a message received by client from daemon.
|
|
151
|
+
*
|
|
152
|
+
* @throws {SbrpError} with code SequenceError if replay detected
|
|
153
|
+
* @throws {SbrpError} with code DecryptFailed if decryption fails
|
|
154
|
+
*/
|
|
155
|
+
export function decryptDaemonToClient(
|
|
156
|
+
session: DaemonSession,
|
|
157
|
+
message: EncryptedMessage,
|
|
158
|
+
): Uint8Array {
|
|
159
|
+
// Check replay protection
|
|
160
|
+
const seq = extractSequence(message.data);
|
|
161
|
+
if (!checkAndUpdateReplay(seq, session.daemonToClient.recvWindow)) {
|
|
162
|
+
throw new SbrpError(
|
|
163
|
+
SbrpErrorCode.SequenceError,
|
|
164
|
+
"Sequence number outside valid window or replay detected",
|
|
165
|
+
);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
try {
|
|
169
|
+
return decrypt(session.daemonToClient.trafficKey, message.data);
|
|
170
|
+
} catch {
|
|
171
|
+
throw new SbrpError(
|
|
172
|
+
SbrpErrorCode.DecryptFailed,
|
|
173
|
+
"Message decryption failed",
|
|
174
|
+
);
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Clear all key material from a client session.
|
|
180
|
+
*
|
|
181
|
+
* Best-effort zeroization (JS/GC limitations apply).
|
|
182
|
+
*/
|
|
183
|
+
export function clearClientSession(session: ClientSession): void {
|
|
184
|
+
zeroize(session.clientToDaemon.trafficKey);
|
|
185
|
+
zeroize(session.daemonToClient.trafficKey);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Clear all key material from a daemon session.
|
|
190
|
+
*
|
|
191
|
+
* Best-effort zeroization (JS/GC limitations apply).
|
|
192
|
+
*/
|
|
193
|
+
export function clearDaemonSession(session: DaemonSession): void {
|
|
194
|
+
zeroize(session.clientToDaemon.trafficKey);
|
|
195
|
+
zeroize(session.daemonToClient.trafficKey);
|
|
196
|
+
}
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2025-present Sideband
|
|
2
|
+
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Type definitions for Sideband Relay Protocol (SBRP).
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/** Branded type for daemon identifiers */
|
|
9
|
+
export type DaemonId = string & { readonly __brand: "DaemonId" };
|
|
10
|
+
|
|
11
|
+
/** Branded type for client session identifiers (relay-assigned) */
|
|
12
|
+
export type ClientId = string & { readonly __brand: "ClientId" };
|
|
13
|
+
|
|
14
|
+
/** Ed25519 identity keypair for daemon authentication */
|
|
15
|
+
export interface IdentityKeyPair {
|
|
16
|
+
publicKey: Uint8Array; // 32 bytes
|
|
17
|
+
privateKey: Uint8Array; // 32 bytes (seed) or 64 bytes (expanded)
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** X25519 ephemeral keypair for key exchange */
|
|
21
|
+
export interface EphemeralKeyPair {
|
|
22
|
+
publicKey: Uint8Array; // 32 bytes
|
|
23
|
+
privateKey: Uint8Array; // 32 bytes
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* TOFU trust record for daemon identity.
|
|
28
|
+
* Pinned on first connect, verified on reconnect to detect MITM.
|
|
29
|
+
* Per-client; not synced via relay.
|
|
30
|
+
*/
|
|
31
|
+
export interface PinnedIdentity {
|
|
32
|
+
daemonId: DaemonId;
|
|
33
|
+
identityPublicKey: Uint8Array; // 32 bytes Ed25519 public key
|
|
34
|
+
firstSeen: Date;
|
|
35
|
+
lastSeen: Date;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Session keys derived from handshake (directional symmetric keys) */
|
|
39
|
+
export interface SessionKeys {
|
|
40
|
+
/** Key for encrypting client→daemon messages */
|
|
41
|
+
clientToDaemon: Uint8Array; // 32 bytes
|
|
42
|
+
/** Key for encrypting daemon→client messages */
|
|
43
|
+
daemonToClient: Uint8Array; // 32 bytes
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Handshake init message (client → daemon) */
|
|
47
|
+
export interface HandshakeInit {
|
|
48
|
+
type: "handshake.init";
|
|
49
|
+
initPublicKey: Uint8Array; // X25519 ephemeral public key
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Handshake accept message (daemon → client) */
|
|
53
|
+
export interface HandshakeAccept {
|
|
54
|
+
type: "handshake.accept";
|
|
55
|
+
acceptPublicKey: Uint8Array; // X25519 ephemeral public key
|
|
56
|
+
signature: Uint8Array; // Ed25519 signature
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Encrypted message envelope */
|
|
60
|
+
export interface EncryptedMessage {
|
|
61
|
+
type: "encrypted";
|
|
62
|
+
seq: bigint;
|
|
63
|
+
data: Uint8Array; // nonce || ciphertext || authTag
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Direction of message flow (used in nonce construction) */
|
|
67
|
+
export const enum Direction {
|
|
68
|
+
ClientToDaemon = 1,
|
|
69
|
+
DaemonToClient = 2,
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** SBRP error codes */
|
|
73
|
+
export const enum SbrpErrorCode {
|
|
74
|
+
Unauthorized = "unauthorized",
|
|
75
|
+
DaemonNotFound = "daemon_not_found",
|
|
76
|
+
DaemonOffline = "daemon_offline",
|
|
77
|
+
DaemonNotOwned = "daemon_not_owned",
|
|
78
|
+
IdentityKeyChanged = "identity_key_changed",
|
|
79
|
+
HandshakeFailed = "handshake_failed",
|
|
80
|
+
DecryptFailed = "decrypt_failed",
|
|
81
|
+
SequenceError = "sequence_error",
|
|
82
|
+
RateLimited = "rate_limited",
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** SBRP-specific error */
|
|
86
|
+
export class SbrpError extends Error {
|
|
87
|
+
constructor(
|
|
88
|
+
public readonly code: SbrpErrorCode,
|
|
89
|
+
message: string,
|
|
90
|
+
) {
|
|
91
|
+
super(message);
|
|
92
|
+
this.name = "SbrpError";
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Brand a string as DaemonId (no validation) */
|
|
97
|
+
export function asDaemonId(value: string): DaemonId {
|
|
98
|
+
return value as DaemonId;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** Brand a string as ClientId (no validation) */
|
|
102
|
+
export function asClientId(value: string): ClientId {
|
|
103
|
+
return value as ClientId;
|
|
104
|
+
}
|