@peers-app/peers-sdk 0.25.6 → 0.25.7
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/dist/data/devices.d.ts +44 -2
- package/dist/data/devices.js +16 -1
- package/dist/device/binary-peer-connection-v2.d.ts +11 -2
- package/dist/device/binary-peer-connection-v2.js +150 -18
- package/dist/device/binary-peer-connection-v2.test.js +217 -5
- package/dist/device/connection.d.ts +79 -9
- package/dist/device/connection.js +185 -26
- package/dist/device/connection.test.js +224 -0
- package/dist/device/device.d.ts +53 -13
- package/dist/device/device.js +55 -2
- package/dist/device/frame-cipher.d.ts +133 -0
- package/dist/device/frame-cipher.js +212 -0
- package/dist/device/frame-cipher.test.d.ts +1 -0
- package/dist/device/frame-cipher.test.js +219 -0
- package/dist/device/socket.type.d.ts +42 -1
- package/dist/device/socket.type.js +2 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/keys.d.ts +19 -0
- package/dist/keys.js +28 -0
- package/dist/rpc-types.d.ts +2 -0
- package/package.json +2 -1
package/dist/device/device.d.ts
CHANGED
|
@@ -1,10 +1,28 @@
|
|
|
1
|
-
import { type IDeviceHandshake, type IDeviceInfo } from "../data";
|
|
1
|
+
import { type IDeviceHandshake, type IDeviceInfo, type IHandshakeFrameCrypto } from "../data";
|
|
2
2
|
import { type IDataBox, type ISignedObject } from "../keys";
|
|
3
|
+
import { type DerivedFrameKeys } from "./frame-cipher";
|
|
3
4
|
/** Optional presentation metadata advertised with signed device identity. */
|
|
4
5
|
export interface IDeviceDisplayInfo {
|
|
5
6
|
userName?: string;
|
|
6
7
|
deviceName?: string;
|
|
7
8
|
}
|
|
9
|
+
/** Inputs for {@link Device.deriveFrameKeys}. */
|
|
10
|
+
export interface DeviceFrameKeyParams {
|
|
11
|
+
/** Remote device's static X25519 public key (base64url), from its signed handshake. */
|
|
12
|
+
remotePublicBoxKey: string;
|
|
13
|
+
/** This connection's local ephemeral X25519 secret key (raw 32 bytes). Zeroized on return. */
|
|
14
|
+
ephemeralSecretKey: Uint8Array;
|
|
15
|
+
/** Remote ephemeral X25519 public key (base64url), from `frameCrypto.ephemeralBoxKey`. */
|
|
16
|
+
remoteEphemeralBoxKey: string;
|
|
17
|
+
/** Connection ID shared by both sockets. */
|
|
18
|
+
connectionId: string;
|
|
19
|
+
/** `deviceId` of the handshake initiator. */
|
|
20
|
+
clientDeviceId: string;
|
|
21
|
+
/** `deviceId` of the handshake responder. */
|
|
22
|
+
serverDeviceId: string;
|
|
23
|
+
/** `timestamp` from the client's signed handshake. */
|
|
24
|
+
timestamp: number;
|
|
25
|
+
}
|
|
8
26
|
/** Runtime cryptographic identity for a Peers device. */
|
|
9
27
|
export declare class Device implements IDeviceInfo {
|
|
10
28
|
readonly userId: string;
|
|
@@ -13,33 +31,55 @@ export declare class Device implements IDeviceInfo {
|
|
|
13
31
|
private _userName?;
|
|
14
32
|
private _deviceName?;
|
|
15
33
|
constructor(userId?: string, deviceId?: string, keys?: import("../keys").IPublicPrivateKeys, displayInfo?: IDeviceDisplayInfo);
|
|
34
|
+
/** Bounded user display name advertised in signed identity messages. */
|
|
16
35
|
get userName(): string | undefined;
|
|
36
|
+
/** Bounded device display name advertised in signed identity messages. */
|
|
17
37
|
get deviceName(): string | undefined;
|
|
38
|
+
/** Ed25519 signing public key, base64url. */
|
|
18
39
|
get publicKey(): string;
|
|
40
|
+
/** X25519 box public key derived from the signing key, base64url. */
|
|
19
41
|
get publicBoxKey(): string;
|
|
42
|
+
/** Signs a UTF-8 message with this device's key (NaCl combined format, base64url). */
|
|
20
43
|
signMessageWithSecretKey(msg: string): string;
|
|
44
|
+
/** Detached-signs `obj` over its canonical JSON with this device's key. */
|
|
21
45
|
signObjectWithSecretKey<T>(obj: T): ISignedObject<T>;
|
|
46
|
+
/** Boxes `data` to `toPublicKey` (X25519, base64url) from this device's box key. */
|
|
22
47
|
boxDataWithKeys(data: any, toPublicKey: string): IDataBox;
|
|
48
|
+
/** Boxes `data` to a device's advertised `publicBoxKey`. */
|
|
23
49
|
boxDataForDevice(data: any, device: IDeviceInfo): IDataBox;
|
|
50
|
+
/** Opens a box addressed to this device. @throws on authentication failure */
|
|
24
51
|
openBoxWithSecretKey(data: IDataBox): any;
|
|
52
|
+
/** Signs then boxes `data` for a device: the legacy per-message RPC envelope. */
|
|
25
53
|
signAndBoxDataForDevice<T>(data: T, device: IDeviceInfo): IDataBox;
|
|
54
|
+
/** Signs then boxes `data` to a raw X25519 public key (base64url). */
|
|
26
55
|
signAndBoxDataForKey<T>(data: T, toPublicBoxKey: string): IDataBox;
|
|
56
|
+
/**
|
|
57
|
+
* Derives this peer's frame-cipher keys for one connection without exposing
|
|
58
|
+
* the static secret key. Computes the ephemeral-ephemeral and static-static
|
|
59
|
+
* X25519 shared secrets, feeds them to `frame-cipher.deriveFrameKeys`, and
|
|
60
|
+
* zeroizes both secrets and the supplied ephemeral secret key.
|
|
61
|
+
*/
|
|
62
|
+
deriveFrameKeys(params: DeviceFrameKeyParams): DerivedFrameKeys;
|
|
63
|
+
/**
|
|
64
|
+
* Unwraps a possibly boxed and/or signed RPC value down to its contents.
|
|
65
|
+
* Plain values pass through unchanged.
|
|
66
|
+
*/
|
|
27
67
|
unwrapResponse<T>(response: T | ISignedObject<T> | IDataBox): T;
|
|
68
|
+
/** Opens a box addressed to this device and verifies the signed object inside. */
|
|
28
69
|
openBoxedAndSignedData<T>(data: IDataBox): T;
|
|
29
70
|
/** Updates bounded presentation metadata included in future signed identity messages. */
|
|
30
71
|
setDisplayInfo({ userName, deviceName }: IDeviceDisplayInfo): void;
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
};
|
|
72
|
+
/**
|
|
73
|
+
* Builds and signs the client half of a connection handshake.
|
|
74
|
+
* @param frameCrypto Include to advertise frame-cipher support with this connection's ephemeral key.
|
|
75
|
+
*/
|
|
76
|
+
getHandshake(connectionId: string, serverAddress: string, frameCrypto?: IHandshakeFrameCrypto): ISignedObject<IDeviceHandshake>;
|
|
77
|
+
/**
|
|
78
|
+
* Validates a client handshake (signature, claimed key, connection ID) and
|
|
79
|
+
* builds the unsigned server response. `Connection` signs and boxes it.
|
|
80
|
+
* @param frameCrypto Include to accept the client's frame-cipher offer with this side's ephemeral key.
|
|
81
|
+
*/
|
|
82
|
+
handshakeResponse(remoteHandshake: ISignedObject<IDeviceHandshake>, connectionId: string, thisServerAddress: string, frameCrypto?: IHandshakeFrameCrypto): IDeviceHandshake;
|
|
43
83
|
private deviceInfoSigned;
|
|
44
84
|
/** Returns cached signed identity information, refreshing after display metadata changes. */
|
|
45
85
|
getDeviceInfo(): ISignedObject<IDeviceInfo>;
|
package/dist/device/device.js
CHANGED
|
@@ -4,6 +4,7 @@ exports.Device = void 0;
|
|
|
4
4
|
const data_1 = require("../data");
|
|
5
5
|
const keys_1 = require("../keys");
|
|
6
6
|
const utils_1 = require("../utils");
|
|
7
|
+
const frame_cipher_1 = require("./frame-cipher");
|
|
7
8
|
/** Runtime cryptographic identity for a Peers device. */
|
|
8
9
|
class Device {
|
|
9
10
|
userId;
|
|
@@ -17,41 +18,81 @@ class Device {
|
|
|
17
18
|
this.keys = keys;
|
|
18
19
|
this.setDisplayInfo(displayInfo);
|
|
19
20
|
}
|
|
21
|
+
/** Bounded user display name advertised in signed identity messages. */
|
|
20
22
|
get userName() {
|
|
21
23
|
return this._userName;
|
|
22
24
|
}
|
|
25
|
+
/** Bounded device display name advertised in signed identity messages. */
|
|
23
26
|
get deviceName() {
|
|
24
27
|
return this._deviceName;
|
|
25
28
|
}
|
|
29
|
+
/** Ed25519 signing public key, base64url. */
|
|
26
30
|
get publicKey() {
|
|
27
31
|
return this.keys.publicKey;
|
|
28
32
|
}
|
|
33
|
+
/** X25519 box public key derived from the signing key, base64url. */
|
|
29
34
|
get publicBoxKey() {
|
|
30
35
|
return this.keys.publicBoxKey;
|
|
31
36
|
}
|
|
37
|
+
/** Signs a UTF-8 message with this device's key (NaCl combined format, base64url). */
|
|
32
38
|
signMessageWithSecretKey(msg) {
|
|
33
39
|
return (0, keys_1.signMessageWithSecretKey)(msg, this.keys.secretKey);
|
|
34
40
|
}
|
|
41
|
+
/** Detached-signs `obj` over its canonical JSON with this device's key. */
|
|
35
42
|
signObjectWithSecretKey(obj) {
|
|
36
43
|
return (0, keys_1.signObjectWithSecretKey)(obj, this.keys.secretKey);
|
|
37
44
|
}
|
|
45
|
+
/** Boxes `data` to `toPublicKey` (X25519, base64url) from this device's box key. */
|
|
38
46
|
boxDataWithKeys(data, toPublicKey) {
|
|
39
47
|
return (0, keys_1.boxDataWithKeys)(data, toPublicKey, this.keys.secretKey);
|
|
40
48
|
}
|
|
49
|
+
/** Boxes `data` to a device's advertised `publicBoxKey`. */
|
|
41
50
|
boxDataForDevice(data, device) {
|
|
42
51
|
return this.boxDataWithKeys(data, device.publicBoxKey);
|
|
43
52
|
}
|
|
53
|
+
/** Opens a box addressed to this device. @throws on authentication failure */
|
|
44
54
|
openBoxWithSecretKey(data) {
|
|
45
55
|
return (0, keys_1.openBoxWithSecretKey)(data, this.keys.secretKey);
|
|
46
56
|
}
|
|
57
|
+
/** Signs then boxes `data` for a device: the legacy per-message RPC envelope. */
|
|
47
58
|
signAndBoxDataForDevice(data, device) {
|
|
48
59
|
const signedData = this.signObjectWithSecretKey(data);
|
|
49
60
|
return this.boxDataForDevice(signedData, device);
|
|
50
61
|
}
|
|
62
|
+
/** Signs then boxes `data` to a raw X25519 public key (base64url). */
|
|
51
63
|
signAndBoxDataForKey(data, toPublicBoxKey) {
|
|
52
64
|
const signedData = this.signObjectWithSecretKey(data);
|
|
53
65
|
return this.boxDataWithKeys(signedData, toPublicBoxKey);
|
|
54
66
|
}
|
|
67
|
+
/**
|
|
68
|
+
* Derives this peer's frame-cipher keys for one connection without exposing
|
|
69
|
+
* the static secret key. Computes the ephemeral-ephemeral and static-static
|
|
70
|
+
* X25519 shared secrets, feeds them to `frame-cipher.deriveFrameKeys`, and
|
|
71
|
+
* zeroizes both secrets and the supplied ephemeral secret key.
|
|
72
|
+
*/
|
|
73
|
+
deriveFrameKeys(params) {
|
|
74
|
+
const ephemeralSharedSecret = (0, keys_1.computeEphemeralSharedSecret)(params.ephemeralSecretKey, params.remoteEphemeralBoxKey);
|
|
75
|
+
const staticSharedSecret = (0, keys_1.computeStaticSharedSecret)(this.keys.secretKey, params.remotePublicBoxKey);
|
|
76
|
+
try {
|
|
77
|
+
return (0, frame_cipher_1.deriveFrameKeys)({
|
|
78
|
+
ephemeralSharedSecret,
|
|
79
|
+
staticSharedSecret,
|
|
80
|
+
connectionId: params.connectionId,
|
|
81
|
+
clientDeviceId: params.clientDeviceId,
|
|
82
|
+
serverDeviceId: params.serverDeviceId,
|
|
83
|
+
timestamp: params.timestamp,
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
finally {
|
|
87
|
+
ephemeralSharedSecret.fill(0);
|
|
88
|
+
staticSharedSecret.fill(0);
|
|
89
|
+
params.ephemeralSecretKey.fill(0);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* Unwraps a possibly boxed and/or signed RPC value down to its contents.
|
|
94
|
+
* Plain values pass through unchanged.
|
|
95
|
+
*/
|
|
55
96
|
unwrapResponse(response) {
|
|
56
97
|
if (!response) {
|
|
57
98
|
return response;
|
|
@@ -72,6 +113,7 @@ class Device {
|
|
|
72
113
|
}
|
|
73
114
|
return response;
|
|
74
115
|
}
|
|
116
|
+
/** Opens a box addressed to this device and verifies the signed object inside. */
|
|
75
117
|
openBoxedAndSignedData(data) {
|
|
76
118
|
const signedData = this.openBoxWithSecretKey(data);
|
|
77
119
|
return (0, keys_1.openSignedObject)(signedData);
|
|
@@ -89,7 +131,11 @@ class Device {
|
|
|
89
131
|
this._deviceName = deviceName;
|
|
90
132
|
this.deviceInfoSigned = undefined;
|
|
91
133
|
}
|
|
92
|
-
|
|
134
|
+
/**
|
|
135
|
+
* Builds and signs the client half of a connection handshake.
|
|
136
|
+
* @param frameCrypto Include to advertise frame-cipher support with this connection's ephemeral key.
|
|
137
|
+
*/
|
|
138
|
+
getHandshake(connectionId, serverAddress, frameCrypto) {
|
|
93
139
|
try {
|
|
94
140
|
const localDeviceInfo = data_1.deviceHandshakeSchema.parse({
|
|
95
141
|
connectionId,
|
|
@@ -101,6 +147,7 @@ class Device {
|
|
|
101
147
|
deviceName: this.deviceName,
|
|
102
148
|
serverAddress,
|
|
103
149
|
timestamp: Date.now(),
|
|
150
|
+
frameCrypto,
|
|
104
151
|
});
|
|
105
152
|
const localDeviceInfoSigned = this.signObjectWithSecretKey(localDeviceInfo);
|
|
106
153
|
return localDeviceInfoSigned;
|
|
@@ -109,7 +156,12 @@ class Device {
|
|
|
109
156
|
throw new Error(`Failed to handshake: ${e.message}`, { cause: e });
|
|
110
157
|
}
|
|
111
158
|
}
|
|
112
|
-
|
|
159
|
+
/**
|
|
160
|
+
* Validates a client handshake (signature, claimed key, connection ID) and
|
|
161
|
+
* builds the unsigned server response. `Connection` signs and boxes it.
|
|
162
|
+
* @param frameCrypto Include to accept the client's frame-cipher offer with this side's ephemeral key.
|
|
163
|
+
*/
|
|
164
|
+
handshakeResponse(remoteHandshake, connectionId, thisServerAddress, frameCrypto) {
|
|
113
165
|
try {
|
|
114
166
|
const deviceInfo = data_1.deviceHandshakeSchema.parse((0, keys_1.openSignedObject)(remoteHandshake));
|
|
115
167
|
if (remoteHandshake.publicKey !== deviceInfo.publicKey) {
|
|
@@ -128,6 +180,7 @@ class Device {
|
|
|
128
180
|
deviceName: this.deviceName,
|
|
129
181
|
serverAddress: thisServerAddress,
|
|
130
182
|
timestamp: Date.now(),
|
|
183
|
+
frameCrypto,
|
|
131
184
|
});
|
|
132
185
|
return handshakeResponse;
|
|
133
186
|
}
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* frame-cipher.ts
|
|
3
|
+
*
|
|
4
|
+
* Per-connection AEAD layer for `binary-peer-connection-v2` frames.
|
|
5
|
+
*
|
|
6
|
+
* Every frame between two devices that both support `frameCrypto` v1 is sealed
|
|
7
|
+
* with ChaCha20-Poly1305 (RFC 8439) under a per-direction key derived from the
|
|
8
|
+
* handshake. This module is pure: it knows nothing about sockets, only bytes.
|
|
9
|
+
*
|
|
10
|
+
* Wire format of a sealed frame:
|
|
11
|
+
*
|
|
12
|
+
* ```
|
|
13
|
+
* [0x20 = FRAME_ENC][ciphertext of the entire inner frame][16-byte Poly1305 tag]
|
|
14
|
+
* ```
|
|
15
|
+
*
|
|
16
|
+
* The inner frame is a normal v2 frame including its own type byte, so the
|
|
17
|
+
* router opens the frame and then dispatches exactly as it would for a
|
|
18
|
+
* plaintext frame. Frame types are not visible on the wire.
|
|
19
|
+
*
|
|
20
|
+
* Nonces are implicit: a 12-byte big-endian counter per direction, starting at
|
|
21
|
+
* zero and never transmitted. Both transports are ordered and reliable, so a
|
|
22
|
+
* lost or reordered frame is a transport failure and surfaces as a tag
|
|
23
|
+
* mismatch. AAD is the UTF-8 `connectionId`, binding every frame to the
|
|
24
|
+
* connection it was sealed for.
|
|
25
|
+
*
|
|
26
|
+
* Design: `working/transport-security.md`.
|
|
27
|
+
*/
|
|
28
|
+
/** Frame type byte for a sealed frame. The only cleartext byte on the wire once armed. */
|
|
29
|
+
export declare const FRAME_ENC = 32;
|
|
30
|
+
/** Poly1305 authentication tag length in bytes. */
|
|
31
|
+
export declare const FRAME_CIPHER_TAG_BYTES = 16;
|
|
32
|
+
/** Bytes added to every frame by the cipher: 1 type byte plus the tag. */
|
|
33
|
+
export declare const FRAME_CIPHER_OVERHEAD: number;
|
|
34
|
+
/** ChaCha20-Poly1305 key length in bytes. */
|
|
35
|
+
export declare const FRAME_CIPHER_KEY_BYTES = 32;
|
|
36
|
+
/** Directional keys for one connection, from the point of view of one peer. */
|
|
37
|
+
export interface FrameKeys {
|
|
38
|
+
/** Key this peer seals outbound frames with. */
|
|
39
|
+
sendKey: Uint8Array;
|
|
40
|
+
/** Key this peer opens inbound frames with. */
|
|
41
|
+
receiveKey: Uint8Array;
|
|
42
|
+
}
|
|
43
|
+
/** Both directional keys, named by direction rather than by role. */
|
|
44
|
+
export interface DerivedFrameKeys {
|
|
45
|
+
/** Key for frames the handshake client sends to the handshake server. */
|
|
46
|
+
clientToServer: Uint8Array;
|
|
47
|
+
/** Key for frames the handshake server sends to the handshake client. */
|
|
48
|
+
serverToClient: Uint8Array;
|
|
49
|
+
}
|
|
50
|
+
/** Inputs to {@link deriveFrameKeys}. Both peers must supply identical values. */
|
|
51
|
+
export interface FrameKeyDerivationParams {
|
|
52
|
+
/** `X25519(eph_local, eph_remote)`: provides forward secrecy. */
|
|
53
|
+
ephemeralSharedSecret: Uint8Array;
|
|
54
|
+
/** `X25519(static_local, static_remote)`: binds the keys to the signed identities. */
|
|
55
|
+
staticSharedSecret: Uint8Array;
|
|
56
|
+
/** Connection ID shared by both sockets; used as the HKDF salt and the frame AAD. */
|
|
57
|
+
connectionId: string;
|
|
58
|
+
/** `deviceId` of the peer that initiated the handshake. */
|
|
59
|
+
clientDeviceId: string;
|
|
60
|
+
/** `deviceId` of the peer that completed the handshake. */
|
|
61
|
+
serverDeviceId: string;
|
|
62
|
+
/** `timestamp` from the client's signed handshake. */
|
|
63
|
+
timestamp: number;
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* Derives the two directional frame keys for a connection.
|
|
67
|
+
*
|
|
68
|
+
* ```
|
|
69
|
+
* ikm = ephemeralSharedSecret || staticSharedSecret
|
|
70
|
+
* salt = connectionId
|
|
71
|
+
* info = "peers-frame-v1" || len(clientDeviceId) || clientDeviceId
|
|
72
|
+
* || len(serverDeviceId) || serverDeviceId || timestamp (8-byte BE)
|
|
73
|
+
* okm = HKDF-SHA256(ikm, salt, info, 64)
|
|
74
|
+
* clientToServer = okm[0..32], serverToClient = okm[32..64]
|
|
75
|
+
* ```
|
|
76
|
+
*
|
|
77
|
+
* Symmetric: both peers compute the same pair, then pick their {@link FrameKeys}
|
|
78
|
+
* with {@link frameKeysForRole}. The caller owns the shared secrets and should
|
|
79
|
+
* zeroize them once this returns.
|
|
80
|
+
*/
|
|
81
|
+
export declare function deriveFrameKeys(params: FrameKeyDerivationParams): DerivedFrameKeys;
|
|
82
|
+
/** Which side of the handshake a peer played. */
|
|
83
|
+
export type FrameCipherRole = "client" | "server";
|
|
84
|
+
/** Selects a peer's send/receive keys from the direction-named pair. */
|
|
85
|
+
export declare function frameKeysForRole(derived: DerivedFrameKeys, role: FrameCipherRole): FrameKeys;
|
|
86
|
+
/**
|
|
87
|
+
* One direction of the frame cipher: a key, the connection AAD, and a counter.
|
|
88
|
+
*
|
|
89
|
+
* Instantiate one {@link FrameSealer} for outbound frames and one
|
|
90
|
+
* {@link FrameOpener} for inbound frames; never share an instance between
|
|
91
|
+
* directions, because the nonce counter must track exactly one stream.
|
|
92
|
+
*/
|
|
93
|
+
declare abstract class FrameCipherDirection {
|
|
94
|
+
protected readonly key: Uint8Array;
|
|
95
|
+
protected readonly aad: Uint8Array;
|
|
96
|
+
private readonly nonce;
|
|
97
|
+
private used;
|
|
98
|
+
/**
|
|
99
|
+
* @param key 32-byte ChaCha20-Poly1305 key for this direction (owned by the instance)
|
|
100
|
+
* @param connectionId Connection ID used as AAD
|
|
101
|
+
*/
|
|
102
|
+
constructor(key: Uint8Array, connectionId: string);
|
|
103
|
+
/** Number of frames processed so far in this direction. */
|
|
104
|
+
get count(): number;
|
|
105
|
+
/** Returns the nonce for the next frame and advances the counter. */
|
|
106
|
+
protected nextNonce(): Uint8Array;
|
|
107
|
+
/** Overwrites the key material. The instance is unusable afterwards. */
|
|
108
|
+
destroy(): void;
|
|
109
|
+
}
|
|
110
|
+
/** Seals outbound frames. One instance per connection direction. */
|
|
111
|
+
export declare class FrameSealer extends FrameCipherDirection {
|
|
112
|
+
/**
|
|
113
|
+
* Seals a complete inner frame (type byte included) into a `FRAME_ENC` frame.
|
|
114
|
+
* Output length is `inner.length + FRAME_CIPHER_OVERHEAD`.
|
|
115
|
+
*/
|
|
116
|
+
seal(inner: Uint8Array): Uint8Array;
|
|
117
|
+
}
|
|
118
|
+
/** Opens inbound frames. One instance per connection direction. */
|
|
119
|
+
export declare class FrameOpener extends FrameCipherDirection {
|
|
120
|
+
/**
|
|
121
|
+
* Opens a `FRAME_ENC` frame and returns the inner frame, or `null` if the
|
|
122
|
+
* frame is malformed or fails authentication. The counter advances on every
|
|
123
|
+
* call, including failures, because a failed frame means the ordered stream
|
|
124
|
+
* is already broken and the caller must close the connection.
|
|
125
|
+
*/
|
|
126
|
+
open(frame: Uint8Array): Uint8Array | null;
|
|
127
|
+
}
|
|
128
|
+
/** Convenience: builds both directions for one peer from its {@link FrameKeys}. */
|
|
129
|
+
export declare function createFrameCiphers(keys: FrameKeys, connectionId: string): {
|
|
130
|
+
sealer: FrameSealer;
|
|
131
|
+
opener: FrameOpener;
|
|
132
|
+
};
|
|
133
|
+
export {};
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* frame-cipher.ts
|
|
4
|
+
*
|
|
5
|
+
* Per-connection AEAD layer for `binary-peer-connection-v2` frames.
|
|
6
|
+
*
|
|
7
|
+
* Every frame between two devices that both support `frameCrypto` v1 is sealed
|
|
8
|
+
* with ChaCha20-Poly1305 (RFC 8439) under a per-direction key derived from the
|
|
9
|
+
* handshake. This module is pure: it knows nothing about sockets, only bytes.
|
|
10
|
+
*
|
|
11
|
+
* Wire format of a sealed frame:
|
|
12
|
+
*
|
|
13
|
+
* ```
|
|
14
|
+
* [0x20 = FRAME_ENC][ciphertext of the entire inner frame][16-byte Poly1305 tag]
|
|
15
|
+
* ```
|
|
16
|
+
*
|
|
17
|
+
* The inner frame is a normal v2 frame including its own type byte, so the
|
|
18
|
+
* router opens the frame and then dispatches exactly as it would for a
|
|
19
|
+
* plaintext frame. Frame types are not visible on the wire.
|
|
20
|
+
*
|
|
21
|
+
* Nonces are implicit: a 12-byte big-endian counter per direction, starting at
|
|
22
|
+
* zero and never transmitted. Both transports are ordered and reliable, so a
|
|
23
|
+
* lost or reordered frame is a transport failure and surfaces as a tag
|
|
24
|
+
* mismatch. AAD is the UTF-8 `connectionId`, binding every frame to the
|
|
25
|
+
* connection it was sealed for.
|
|
26
|
+
*
|
|
27
|
+
* Design: `working/transport-security.md`.
|
|
28
|
+
*/
|
|
29
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
30
|
+
exports.FrameOpener = exports.FrameSealer = exports.FRAME_CIPHER_KEY_BYTES = exports.FRAME_CIPHER_OVERHEAD = exports.FRAME_CIPHER_TAG_BYTES = exports.FRAME_ENC = void 0;
|
|
31
|
+
exports.deriveFrameKeys = deriveFrameKeys;
|
|
32
|
+
exports.frameKeysForRole = frameKeysForRole;
|
|
33
|
+
exports.createFrameCiphers = createFrameCiphers;
|
|
34
|
+
const chacha_1 = require("@noble/ciphers/chacha");
|
|
35
|
+
const hkdf_1 = require("@noble/hashes/hkdf");
|
|
36
|
+
const sha2_1 = require("@noble/hashes/sha2");
|
|
37
|
+
/** Frame type byte for a sealed frame. The only cleartext byte on the wire once armed. */
|
|
38
|
+
exports.FRAME_ENC = 0x20;
|
|
39
|
+
/** Poly1305 authentication tag length in bytes. */
|
|
40
|
+
exports.FRAME_CIPHER_TAG_BYTES = 16;
|
|
41
|
+
/** Bytes added to every frame by the cipher: 1 type byte plus the tag. */
|
|
42
|
+
exports.FRAME_CIPHER_OVERHEAD = 1 + exports.FRAME_CIPHER_TAG_BYTES;
|
|
43
|
+
/** ChaCha20-Poly1305 key length in bytes. */
|
|
44
|
+
exports.FRAME_CIPHER_KEY_BYTES = 32;
|
|
45
|
+
/** RFC 8439 nonce length in bytes. */
|
|
46
|
+
const NONCE_BYTES = 12;
|
|
47
|
+
/** HKDF `info` label; bump with the `frameCrypto.version` if the derivation ever changes. */
|
|
48
|
+
const HKDF_LABEL = "peers-frame-v1";
|
|
49
|
+
const utf8 = new TextEncoder();
|
|
50
|
+
function concatBytes(...parts) {
|
|
51
|
+
let length = 0;
|
|
52
|
+
for (const part of parts)
|
|
53
|
+
length += part.length;
|
|
54
|
+
const out = new Uint8Array(length);
|
|
55
|
+
let offset = 0;
|
|
56
|
+
for (const part of parts) {
|
|
57
|
+
out.set(part, offset);
|
|
58
|
+
offset += part.length;
|
|
59
|
+
}
|
|
60
|
+
return out;
|
|
61
|
+
}
|
|
62
|
+
function encodeTimestamp(timestamp) {
|
|
63
|
+
if (!Number.isSafeInteger(timestamp) || timestamp < 0) {
|
|
64
|
+
throw new Error(`Invalid handshake timestamp for key derivation: ${timestamp}`);
|
|
65
|
+
}
|
|
66
|
+
const bytes = new Uint8Array(8);
|
|
67
|
+
new DataView(bytes.buffer).setBigUint64(0, BigInt(timestamp), false);
|
|
68
|
+
return bytes;
|
|
69
|
+
}
|
|
70
|
+
function encodeLengthPrefixed(value) {
|
|
71
|
+
const bytes = utf8.encode(value);
|
|
72
|
+
if (bytes.length > 0xffff) {
|
|
73
|
+
throw new Error("Key derivation input too long");
|
|
74
|
+
}
|
|
75
|
+
const out = new Uint8Array(2 + bytes.length);
|
|
76
|
+
new DataView(out.buffer).setUint16(0, bytes.length, false);
|
|
77
|
+
out.set(bytes, 2);
|
|
78
|
+
return out;
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* Derives the two directional frame keys for a connection.
|
|
82
|
+
*
|
|
83
|
+
* ```
|
|
84
|
+
* ikm = ephemeralSharedSecret || staticSharedSecret
|
|
85
|
+
* salt = connectionId
|
|
86
|
+
* info = "peers-frame-v1" || len(clientDeviceId) || clientDeviceId
|
|
87
|
+
* || len(serverDeviceId) || serverDeviceId || timestamp (8-byte BE)
|
|
88
|
+
* okm = HKDF-SHA256(ikm, salt, info, 64)
|
|
89
|
+
* clientToServer = okm[0..32], serverToClient = okm[32..64]
|
|
90
|
+
* ```
|
|
91
|
+
*
|
|
92
|
+
* Symmetric: both peers compute the same pair, then pick their {@link FrameKeys}
|
|
93
|
+
* with {@link frameKeysForRole}. The caller owns the shared secrets and should
|
|
94
|
+
* zeroize them once this returns.
|
|
95
|
+
*/
|
|
96
|
+
function deriveFrameKeys(params) {
|
|
97
|
+
const { ephemeralSharedSecret, staticSharedSecret, connectionId, clientDeviceId, serverDeviceId, timestamp, } = params;
|
|
98
|
+
if (ephemeralSharedSecret.length !== 32 || staticSharedSecret.length !== 32) {
|
|
99
|
+
throw new Error("Frame key derivation requires two 32-byte X25519 shared secrets");
|
|
100
|
+
}
|
|
101
|
+
if (!connectionId || !clientDeviceId || !serverDeviceId) {
|
|
102
|
+
throw new Error("Frame key derivation requires connectionId and both device IDs");
|
|
103
|
+
}
|
|
104
|
+
const ikm = concatBytes(ephemeralSharedSecret, staticSharedSecret);
|
|
105
|
+
const salt = utf8.encode(connectionId);
|
|
106
|
+
const info = concatBytes(utf8.encode(HKDF_LABEL), encodeLengthPrefixed(clientDeviceId), encodeLengthPrefixed(serverDeviceId), encodeTimestamp(timestamp));
|
|
107
|
+
const okm = (0, hkdf_1.hkdf)(sha2_1.sha256, ikm, salt, info, 2 * exports.FRAME_CIPHER_KEY_BYTES);
|
|
108
|
+
ikm.fill(0);
|
|
109
|
+
return {
|
|
110
|
+
clientToServer: okm.slice(0, exports.FRAME_CIPHER_KEY_BYTES),
|
|
111
|
+
serverToClient: okm.slice(exports.FRAME_CIPHER_KEY_BYTES, 2 * exports.FRAME_CIPHER_KEY_BYTES),
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
/** Selects a peer's send/receive keys from the direction-named pair. */
|
|
115
|
+
function frameKeysForRole(derived, role) {
|
|
116
|
+
return role === "client"
|
|
117
|
+
? { sendKey: derived.clientToServer, receiveKey: derived.serverToClient }
|
|
118
|
+
: { sendKey: derived.serverToClient, receiveKey: derived.clientToServer };
|
|
119
|
+
}
|
|
120
|
+
/**
|
|
121
|
+
* One direction of the frame cipher: a key, the connection AAD, and a counter.
|
|
122
|
+
*
|
|
123
|
+
* Instantiate one {@link FrameSealer} for outbound frames and one
|
|
124
|
+
* {@link FrameOpener} for inbound frames; never share an instance between
|
|
125
|
+
* directions, because the nonce counter must track exactly one stream.
|
|
126
|
+
*/
|
|
127
|
+
class FrameCipherDirection {
|
|
128
|
+
key;
|
|
129
|
+
aad;
|
|
130
|
+
nonce = new Uint8Array(NONCE_BYTES);
|
|
131
|
+
used = 0;
|
|
132
|
+
/**
|
|
133
|
+
* @param key 32-byte ChaCha20-Poly1305 key for this direction (owned by the instance)
|
|
134
|
+
* @param connectionId Connection ID used as AAD
|
|
135
|
+
*/
|
|
136
|
+
constructor(key, connectionId) {
|
|
137
|
+
if (key.length !== exports.FRAME_CIPHER_KEY_BYTES) {
|
|
138
|
+
throw new Error(`Frame cipher key must be ${exports.FRAME_CIPHER_KEY_BYTES} bytes`);
|
|
139
|
+
}
|
|
140
|
+
this.key = key;
|
|
141
|
+
this.aad = utf8.encode(connectionId);
|
|
142
|
+
}
|
|
143
|
+
/** Number of frames processed so far in this direction. */
|
|
144
|
+
get count() {
|
|
145
|
+
return this.used;
|
|
146
|
+
}
|
|
147
|
+
/** Returns the nonce for the next frame and advances the counter. */
|
|
148
|
+
nextNonce() {
|
|
149
|
+
const current = this.nonce.slice();
|
|
150
|
+
let i = NONCE_BYTES - 1;
|
|
151
|
+
while (i >= 0) {
|
|
152
|
+
this.nonce[i] = (this.nonce[i] + 1) & 0xff;
|
|
153
|
+
if (this.nonce[i] !== 0)
|
|
154
|
+
break;
|
|
155
|
+
i--;
|
|
156
|
+
}
|
|
157
|
+
if (i < 0) {
|
|
158
|
+
throw new Error("Frame cipher nonce counter exhausted");
|
|
159
|
+
}
|
|
160
|
+
this.used++;
|
|
161
|
+
return current;
|
|
162
|
+
}
|
|
163
|
+
/** Overwrites the key material. The instance is unusable afterwards. */
|
|
164
|
+
destroy() {
|
|
165
|
+
this.key.fill(0);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
/** Seals outbound frames. One instance per connection direction. */
|
|
169
|
+
class FrameSealer extends FrameCipherDirection {
|
|
170
|
+
/**
|
|
171
|
+
* Seals a complete inner frame (type byte included) into a `FRAME_ENC` frame.
|
|
172
|
+
* Output length is `inner.length + FRAME_CIPHER_OVERHEAD`.
|
|
173
|
+
*/
|
|
174
|
+
seal(inner) {
|
|
175
|
+
const nonce = this.nextNonce();
|
|
176
|
+
const sealed = (0, chacha_1.chacha20poly1305)(this.key, nonce, this.aad).encrypt(inner);
|
|
177
|
+
const frame = new Uint8Array(1 + sealed.length);
|
|
178
|
+
frame[0] = exports.FRAME_ENC;
|
|
179
|
+
frame.set(sealed, 1);
|
|
180
|
+
return frame;
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
exports.FrameSealer = FrameSealer;
|
|
184
|
+
/** Opens inbound frames. One instance per connection direction. */
|
|
185
|
+
class FrameOpener extends FrameCipherDirection {
|
|
186
|
+
/**
|
|
187
|
+
* Opens a `FRAME_ENC` frame and returns the inner frame, or `null` if the
|
|
188
|
+
* frame is malformed or fails authentication. The counter advances on every
|
|
189
|
+
* call, including failures, because a failed frame means the ordered stream
|
|
190
|
+
* is already broken and the caller must close the connection.
|
|
191
|
+
*/
|
|
192
|
+
open(frame) {
|
|
193
|
+
if (frame.length < exports.FRAME_CIPHER_OVERHEAD || frame[0] !== exports.FRAME_ENC) {
|
|
194
|
+
return null;
|
|
195
|
+
}
|
|
196
|
+
const nonce = this.nextNonce();
|
|
197
|
+
try {
|
|
198
|
+
return (0, chacha_1.chacha20poly1305)(this.key, nonce, this.aad).decrypt(frame.subarray(1));
|
|
199
|
+
}
|
|
200
|
+
catch {
|
|
201
|
+
return null;
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
exports.FrameOpener = FrameOpener;
|
|
206
|
+
/** Convenience: builds both directions for one peer from its {@link FrameKeys}. */
|
|
207
|
+
function createFrameCiphers(keys, connectionId) {
|
|
208
|
+
return {
|
|
209
|
+
sealer: new FrameSealer(keys.sendKey, connectionId),
|
|
210
|
+
opener: new FrameOpener(keys.receiveKey, connectionId),
|
|
211
|
+
};
|
|
212
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|