@camerontaylor/paseo-relay 0.12.0-fork.1 → 0.13.0-fork.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/dist/e2ee.d.ts +2 -0
- package/dist/e2ee.js +1 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/noise/cipher-state.d.ts +19 -0
- package/dist/noise/cipher-state.js +72 -0
- package/dist/noise/index.d.ts +45 -0
- package/dist/noise/index.js +179 -0
- package/dist/noise/symmetric-state.d.ts +15 -0
- package/dist/noise/symmetric-state.js +71 -0
- package/dist/protected-records.d.ts +34 -0
- package/dist/protected-records.js +179 -0
- package/dist/secure-channel.d.ts +87 -0
- package/dist/secure-channel.js +650 -0
- package/package.json +4 -1
package/dist/e2ee.d.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
export { openClientSecureChannel, openDaemonSecureChannel, PROTECTED_HANDSHAKE_FAILED_CLOSE_CODE, PROTECTED_MODE_REQUIRED_CLOSE_CODE, PROTECTED_MODE_REQUIRED_CLOSE_REASON, PROTECTED_RECORD_FAILED_CLOSE_CODE, SecureChannelRejectedError, } from "./secure-channel.js";
|
|
2
|
+
export type { ClientSecureChannelOptions, DaemonSecureChannelOptions, SecureChannel, SecureChannelEvents, SecurityMode, } from "./secure-channel.js";
|
|
1
3
|
export { createClientChannel, createDaemonChannel, EncryptedChannel } from "./encrypted-channel.js";
|
|
2
4
|
export type { Transport, TransportMessage, EncryptedChannelEvents } from "./encrypted-channel.js";
|
|
3
5
|
export { generateKeyPair, exportPublicKey, importPublicKey, exportSecretKey, importSecretKey, } from "./crypto.js";
|
package/dist/e2ee.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
export { openClientSecureChannel, openDaemonSecureChannel, PROTECTED_HANDSHAKE_FAILED_CLOSE_CODE, PROTECTED_MODE_REQUIRED_CLOSE_CODE, PROTECTED_MODE_REQUIRED_CLOSE_REASON, PROTECTED_RECORD_FAILED_CLOSE_CODE, SecureChannelRejectedError, } from "./secure-channel.js";
|
|
1
2
|
export { createClientChannel, createDaemonChannel, EncryptedChannel } from "./encrypted-channel.js";
|
|
2
3
|
export { generateKeyPair, exportPublicKey, importPublicKey, exportSecretKey, importSecretKey, } from "./crypto.js";
|
|
3
4
|
//# sourceMappingURL=e2ee.js.map
|
package/dist/index.d.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
export type { ConnectionRole, RelaySessionAttachment } from "./types.js";
|
|
2
2
|
export { generateKeyPair, exportPublicKey, importPublicKey, deriveSharedKey, encrypt, decrypt, } from "./crypto.js";
|
|
3
3
|
export { base64EncryptedWireByteLength, createClientChannel, createDaemonChannel, EncryptedChannel, maxBase64EncryptedPlaintextByteLength, } from "./encrypted-channel.js";
|
|
4
|
+
export { maxSecuredPlaintextByteLength } from "./secure-channel.js";
|
|
4
5
|
export type { Transport, EncryptedChannelEvents } from "./encrypted-channel.js";
|
|
5
6
|
//# sourceMappingURL=index.d.ts.map
|
package/dist/index.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
1
|
export { generateKeyPair, exportPublicKey, importPublicKey, deriveSharedKey, encrypt, decrypt, } from "./crypto.js";
|
|
2
2
|
export { base64EncryptedWireByteLength, createClientChannel, createDaemonChannel, EncryptedChannel, maxBase64EncryptedPlaintextByteLength, } from "./encrypted-channel.js";
|
|
3
|
+
export { maxSecuredPlaintextByteLength } from "./secure-channel.js";
|
|
3
4
|
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/** Noise spec §5.1 CipherState for ChaChaPoly. Nonce is 4 zero bytes then 64-bit little-endian n. */
|
|
2
|
+
export declare const NOISE_TAG_BYTES = 16;
|
|
3
|
+
export declare const NOISE_MAX_MESSAGE_BYTES = 65535;
|
|
4
|
+
export declare class NoiseProtocolError extends Error {
|
|
5
|
+
constructor(message: string);
|
|
6
|
+
}
|
|
7
|
+
export declare class CipherState {
|
|
8
|
+
private k;
|
|
9
|
+
private n;
|
|
10
|
+
initializeKey(key: Uint8Array | null, nonce?: bigint): void;
|
|
11
|
+
hasKey(): boolean;
|
|
12
|
+
nonce(): bigint;
|
|
13
|
+
encryptWithAd(ad: Uint8Array, plaintext: Uint8Array): Uint8Array;
|
|
14
|
+
decryptWithAd(ad: Uint8Array, ciphertext: Uint8Array): Uint8Array;
|
|
15
|
+
/** Reserves the next nonce synchronously so concurrent senders never share one. */
|
|
16
|
+
private takeNonce;
|
|
17
|
+
clear(): void;
|
|
18
|
+
}
|
|
19
|
+
//# sourceMappingURL=cipher-state.d.ts.map
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { chacha20poly1305 } from "@noble/ciphers/chacha.js";
|
|
2
|
+
/** Noise spec §5.1 CipherState for ChaChaPoly. Nonce is 4 zero bytes then 64-bit little-endian n. */
|
|
3
|
+
export const NOISE_TAG_BYTES = 16;
|
|
4
|
+
export const NOISE_MAX_MESSAGE_BYTES = 65535;
|
|
5
|
+
const MAX_NONCE = (1n << 64n) - 1n;
|
|
6
|
+
export class NoiseProtocolError extends Error {
|
|
7
|
+
constructor(message) {
|
|
8
|
+
super(message);
|
|
9
|
+
this.name = "NoiseProtocolError";
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
export class CipherState {
|
|
13
|
+
constructor() {
|
|
14
|
+
this.k = null;
|
|
15
|
+
this.n = 0n;
|
|
16
|
+
}
|
|
17
|
+
initializeKey(key, nonce = 0n) {
|
|
18
|
+
this.k = key;
|
|
19
|
+
this.n = nonce;
|
|
20
|
+
}
|
|
21
|
+
hasKey() {
|
|
22
|
+
return this.k !== null;
|
|
23
|
+
}
|
|
24
|
+
nonce() {
|
|
25
|
+
return this.n;
|
|
26
|
+
}
|
|
27
|
+
encryptWithAd(ad, plaintext) {
|
|
28
|
+
if (this.k === null)
|
|
29
|
+
return plaintext;
|
|
30
|
+
const nonce = this.takeNonce();
|
|
31
|
+
return chacha20poly1305(this.k, nonce, ad).encrypt(plaintext);
|
|
32
|
+
}
|
|
33
|
+
decryptWithAd(ad, ciphertext) {
|
|
34
|
+
if (this.k === null)
|
|
35
|
+
return ciphertext;
|
|
36
|
+
if (this.n === MAX_NONCE)
|
|
37
|
+
throw new NoiseProtocolError("nonce exhausted");
|
|
38
|
+
let plaintext;
|
|
39
|
+
try {
|
|
40
|
+
plaintext = chacha20poly1305(this.k, encodeNonce(this.n), ad).decrypt(ciphertext);
|
|
41
|
+
}
|
|
42
|
+
catch {
|
|
43
|
+
throw new NoiseProtocolError("authentication failed");
|
|
44
|
+
}
|
|
45
|
+
// Only a successful authentication advances the receive counter.
|
|
46
|
+
this.n += 1n;
|
|
47
|
+
return plaintext;
|
|
48
|
+
}
|
|
49
|
+
/** Reserves the next nonce synchronously so concurrent senders never share one. */
|
|
50
|
+
takeNonce() {
|
|
51
|
+
if (this.n === MAX_NONCE)
|
|
52
|
+
throw new NoiseProtocolError("nonce exhausted");
|
|
53
|
+
const nonce = encodeNonce(this.n);
|
|
54
|
+
this.n += 1n;
|
|
55
|
+
return nonce;
|
|
56
|
+
}
|
|
57
|
+
clear() {
|
|
58
|
+
this.k?.fill(0);
|
|
59
|
+
this.k = null;
|
|
60
|
+
this.n = MAX_NONCE;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
function encodeNonce(n) {
|
|
64
|
+
const nonce = new Uint8Array(12);
|
|
65
|
+
let value = n;
|
|
66
|
+
for (let i = 4; i < 12; i += 1) {
|
|
67
|
+
nonce[i] = Number(value & 0xffn);
|
|
68
|
+
value >>= 8n;
|
|
69
|
+
}
|
|
70
|
+
return nonce;
|
|
71
|
+
}
|
|
72
|
+
//# sourceMappingURL=cipher-state.js.map
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { CipherState } from "./cipher-state.js";
|
|
2
|
+
export { CipherState, NoiseProtocolError, NOISE_MAX_MESSAGE_BYTES, NOISE_TAG_BYTES, } from "./cipher-state.js";
|
|
3
|
+
export declare const NOISE_NK_PROTOCOL_NAME = "Noise_NK_25519_ChaChaPoly_SHA256";
|
|
4
|
+
export declare const DH_BYTES = 32;
|
|
5
|
+
export interface NoiseKeyPair {
|
|
6
|
+
publicKey: Uint8Array;
|
|
7
|
+
secretKey: Uint8Array;
|
|
8
|
+
}
|
|
9
|
+
export interface NoiseInitiatorOptions {
|
|
10
|
+
/** Responder's static public key, trusted out of band. */
|
|
11
|
+
remoteStatic: Uint8Array;
|
|
12
|
+
prologue: Uint8Array;
|
|
13
|
+
/** Test injection only; production generates a fresh ephemeral. */
|
|
14
|
+
ephemeral?: NoiseKeyPair;
|
|
15
|
+
}
|
|
16
|
+
export interface NoiseResponderOptions {
|
|
17
|
+
localStatic: NoiseKeyPair;
|
|
18
|
+
prologue: Uint8Array;
|
|
19
|
+
/** Test injection only; production generates a fresh ephemeral. */
|
|
20
|
+
ephemeral?: NoiseKeyPair;
|
|
21
|
+
}
|
|
22
|
+
export interface TransportKeys {
|
|
23
|
+
/** Initiator → responder. */
|
|
24
|
+
send: CipherState;
|
|
25
|
+
/** Responder → initiator. */
|
|
26
|
+
receive: CipherState;
|
|
27
|
+
}
|
|
28
|
+
export interface NoiseHandshake {
|
|
29
|
+
/** Builds the next handshake message this side is due to write. */
|
|
30
|
+
writeMessage(payload: Uint8Array): Uint8Array;
|
|
31
|
+
/** Consumes the next handshake message this side is due to read; returns its payload. */
|
|
32
|
+
readMessage(message: Uint8Array): Uint8Array;
|
|
33
|
+
/** True once both handshake messages have been processed. */
|
|
34
|
+
isComplete(): boolean;
|
|
35
|
+
handshakeHash(): Uint8Array;
|
|
36
|
+
/** Derives transport keys, oriented for this side. Ends the handshake state. */
|
|
37
|
+
split(): {
|
|
38
|
+
send: CipherState;
|
|
39
|
+
receive: CipherState;
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
export declare function generateNoiseKeyPair(): NoiseKeyPair;
|
|
43
|
+
export declare function createNoiseInitiator(options: NoiseInitiatorOptions): NoiseHandshake;
|
|
44
|
+
export declare function createNoiseResponder(options: NoiseResponderOptions): NoiseHandshake;
|
|
45
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Conforming Noise_NK_25519_ChaChaPoly_SHA256 (Noise Protocol Framework rev 34).
|
|
3
|
+
*
|
|
4
|
+
* The pattern is executed token by token from its definition, so the vector
|
|
5
|
+
* tests exercise the same code path as production. Only NK is provided; the
|
|
6
|
+
* daemon's static X25519 key is the responder's `s`, known to the initiator
|
|
7
|
+
* from pairing.
|
|
8
|
+
*
|
|
9
|
+
* NK:
|
|
10
|
+
* <- s
|
|
11
|
+
* ...
|
|
12
|
+
* -> e, es
|
|
13
|
+
* <- e, ee
|
|
14
|
+
*/
|
|
15
|
+
import { x25519 } from "@noble/curves/ed25519.js";
|
|
16
|
+
import { NoiseProtocolError, NOISE_MAX_MESSAGE_BYTES } from "./cipher-state.js";
|
|
17
|
+
import { SymmetricState, concat } from "./symmetric-state.js";
|
|
18
|
+
export { CipherState, NoiseProtocolError, NOISE_MAX_MESSAGE_BYTES, NOISE_TAG_BYTES, } from "./cipher-state.js";
|
|
19
|
+
export const NOISE_NK_PROTOCOL_NAME = "Noise_NK_25519_ChaChaPoly_SHA256";
|
|
20
|
+
export const DH_BYTES = 32;
|
|
21
|
+
const NK_MESSAGES = [
|
|
22
|
+
["e", "es"],
|
|
23
|
+
["e", "ee"],
|
|
24
|
+
];
|
|
25
|
+
export function generateNoiseKeyPair() {
|
|
26
|
+
const secretKey = x25519.utils.randomSecretKey();
|
|
27
|
+
return { secretKey, publicKey: x25519.getPublicKey(secretKey) };
|
|
28
|
+
}
|
|
29
|
+
export function createNoiseInitiator(options) {
|
|
30
|
+
requireKeyLength(options.remoteStatic, "remote static key");
|
|
31
|
+
return new HandshakeState({
|
|
32
|
+
initiator: true,
|
|
33
|
+
prologue: options.prologue,
|
|
34
|
+
s: null,
|
|
35
|
+
rs: options.remoteStatic,
|
|
36
|
+
ephemeral: options.ephemeral ?? null,
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
export function createNoiseResponder(options) {
|
|
40
|
+
requireKeyLength(options.localStatic.publicKey, "local static public key");
|
|
41
|
+
requireKeyLength(options.localStatic.secretKey, "local static secret key");
|
|
42
|
+
return new HandshakeState({
|
|
43
|
+
initiator: false,
|
|
44
|
+
prologue: options.prologue,
|
|
45
|
+
s: options.localStatic,
|
|
46
|
+
rs: null,
|
|
47
|
+
ephemeral: options.ephemeral ?? null,
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
class HandshakeState {
|
|
51
|
+
constructor(params) {
|
|
52
|
+
this.symmetric = new SymmetricState(NOISE_NK_PROTOCOL_NAME);
|
|
53
|
+
this.re = null;
|
|
54
|
+
this.messageIndex = 0;
|
|
55
|
+
this.finished = false;
|
|
56
|
+
this.initiator = params.initiator;
|
|
57
|
+
this.s = params.s;
|
|
58
|
+
this.rs = params.rs;
|
|
59
|
+
this.e = params.ephemeral;
|
|
60
|
+
this.symmetric.mixHash(params.prologue);
|
|
61
|
+
// Pre-message "<- s": the responder's static public key.
|
|
62
|
+
const preMessageStatic = this.initiator ? this.rs : this.s?.publicKey;
|
|
63
|
+
if (!preMessageStatic)
|
|
64
|
+
throw new NoiseProtocolError("missing responder static key");
|
|
65
|
+
this.symmetric.mixHash(preMessageStatic);
|
|
66
|
+
}
|
|
67
|
+
writeMessage(payload) {
|
|
68
|
+
const tokens = this.nextTokens(this.initiator);
|
|
69
|
+
const parts = [];
|
|
70
|
+
for (const token of tokens) {
|
|
71
|
+
switch (token) {
|
|
72
|
+
case "e": {
|
|
73
|
+
if (!this.e)
|
|
74
|
+
this.e = generateNoiseKeyPair();
|
|
75
|
+
parts.push(this.e.publicKey);
|
|
76
|
+
this.symmetric.mixHash(this.e.publicKey);
|
|
77
|
+
break;
|
|
78
|
+
}
|
|
79
|
+
case "es":
|
|
80
|
+
this.mixDh(this.initiator ? this.e : this.s, this.initiator ? this.rs : this.re);
|
|
81
|
+
break;
|
|
82
|
+
case "ee":
|
|
83
|
+
this.mixDh(this.e, this.re);
|
|
84
|
+
break;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
parts.push(this.symmetric.encryptAndHash(payload));
|
|
88
|
+
const message = concat(...parts);
|
|
89
|
+
if (message.byteLength > NOISE_MAX_MESSAGE_BYTES) {
|
|
90
|
+
throw new NoiseProtocolError("handshake message too large");
|
|
91
|
+
}
|
|
92
|
+
this.advance();
|
|
93
|
+
return message;
|
|
94
|
+
}
|
|
95
|
+
readMessage(message) {
|
|
96
|
+
if (message.byteLength > NOISE_MAX_MESSAGE_BYTES) {
|
|
97
|
+
throw new NoiseProtocolError("handshake message too large");
|
|
98
|
+
}
|
|
99
|
+
const tokens = this.nextTokens(!this.initiator);
|
|
100
|
+
let offset = 0;
|
|
101
|
+
for (const token of tokens) {
|
|
102
|
+
switch (token) {
|
|
103
|
+
case "e": {
|
|
104
|
+
if (message.byteLength < offset + DH_BYTES) {
|
|
105
|
+
throw new NoiseProtocolError("handshake message truncated");
|
|
106
|
+
}
|
|
107
|
+
this.re = message.slice(offset, offset + DH_BYTES);
|
|
108
|
+
offset += DH_BYTES;
|
|
109
|
+
this.symmetric.mixHash(this.re);
|
|
110
|
+
break;
|
|
111
|
+
}
|
|
112
|
+
case "es":
|
|
113
|
+
this.mixDh(this.initiator ? this.e : this.s, this.initiator ? this.rs : this.re);
|
|
114
|
+
break;
|
|
115
|
+
case "ee":
|
|
116
|
+
this.mixDh(this.e, this.re);
|
|
117
|
+
break;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
const payload = this.symmetric.decryptAndHash(message.slice(offset));
|
|
121
|
+
this.advance();
|
|
122
|
+
return payload;
|
|
123
|
+
}
|
|
124
|
+
isComplete() {
|
|
125
|
+
return this.messageIndex >= NK_MESSAGES.length;
|
|
126
|
+
}
|
|
127
|
+
handshakeHash() {
|
|
128
|
+
return this.symmetric.handshakeHash();
|
|
129
|
+
}
|
|
130
|
+
split() {
|
|
131
|
+
if (!this.isComplete())
|
|
132
|
+
throw new NoiseProtocolError("handshake incomplete");
|
|
133
|
+
if (this.finished)
|
|
134
|
+
throw new NoiseProtocolError("handshake already split");
|
|
135
|
+
this.finished = true;
|
|
136
|
+
const [c1, c2] = this.symmetric.split();
|
|
137
|
+
this.e?.secretKey.fill(0);
|
|
138
|
+
this.e = null;
|
|
139
|
+
return this.initiator ? { send: c1, receive: c2 } : { send: c2, receive: c1 };
|
|
140
|
+
}
|
|
141
|
+
nextTokens(expectedWriterIsInitiator) {
|
|
142
|
+
if (this.isComplete() || this.finished)
|
|
143
|
+
throw new NoiseProtocolError("handshake complete");
|
|
144
|
+
const writerIsInitiator = this.messageIndex % 2 === 0;
|
|
145
|
+
if (writerIsInitiator !== expectedWriterIsInitiator) {
|
|
146
|
+
throw new NoiseProtocolError("handshake message out of turn");
|
|
147
|
+
}
|
|
148
|
+
return NK_MESSAGES[this.messageIndex];
|
|
149
|
+
}
|
|
150
|
+
advance() {
|
|
151
|
+
this.messageIndex += 1;
|
|
152
|
+
}
|
|
153
|
+
mixDh(local, remote) {
|
|
154
|
+
if (!local || !remote)
|
|
155
|
+
throw new NoiseProtocolError("missing DH key");
|
|
156
|
+
let shared;
|
|
157
|
+
try {
|
|
158
|
+
shared = x25519.getSharedSecret(local.secretKey, remote);
|
|
159
|
+
}
|
|
160
|
+
catch {
|
|
161
|
+
throw new NoiseProtocolError("invalid DH public key");
|
|
162
|
+
}
|
|
163
|
+
if (isAllZero(shared))
|
|
164
|
+
throw new NoiseProtocolError("invalid DH public key");
|
|
165
|
+
this.symmetric.mixKey(shared);
|
|
166
|
+
shared.fill(0);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
function requireKeyLength(key, label) {
|
|
170
|
+
if (key.byteLength !== DH_BYTES)
|
|
171
|
+
throw new NoiseProtocolError(`${label} must be 32 bytes`);
|
|
172
|
+
}
|
|
173
|
+
function isAllZero(bytes) {
|
|
174
|
+
let acc = 0;
|
|
175
|
+
for (const byte of bytes)
|
|
176
|
+
acc |= byte;
|
|
177
|
+
return acc === 0;
|
|
178
|
+
}
|
|
179
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { CipherState } from "./cipher-state.js";
|
|
2
|
+
export declare class SymmetricState {
|
|
3
|
+
readonly cipher: CipherState;
|
|
4
|
+
private ck;
|
|
5
|
+
private h;
|
|
6
|
+
constructor(protocolName: string);
|
|
7
|
+
mixKey(inputKeyMaterial: Uint8Array): void;
|
|
8
|
+
mixHash(data: Uint8Array): void;
|
|
9
|
+
handshakeHash(): Uint8Array;
|
|
10
|
+
encryptAndHash(plaintext: Uint8Array): Uint8Array;
|
|
11
|
+
decryptAndHash(ciphertext: Uint8Array): Uint8Array;
|
|
12
|
+
split(): [CipherState, CipherState];
|
|
13
|
+
}
|
|
14
|
+
export declare function concat(...parts: Uint8Array[]): Uint8Array;
|
|
15
|
+
//# sourceMappingURL=symmetric-state.d.ts.map
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { sha256 } from "@noble/hashes/sha2.js";
|
|
2
|
+
import { hmac } from "@noble/hashes/hmac.js";
|
|
3
|
+
import { CipherState } from "./cipher-state.js";
|
|
4
|
+
/** Noise spec §5.2 SymmetricState for SHA256 (HASHLEN 32). */
|
|
5
|
+
const HASHLEN = 32;
|
|
6
|
+
const EMPTY = new Uint8Array(0);
|
|
7
|
+
export class SymmetricState {
|
|
8
|
+
constructor(protocolName) {
|
|
9
|
+
this.cipher = new CipherState();
|
|
10
|
+
const name = new TextEncoder().encode(protocolName);
|
|
11
|
+
if (name.byteLength <= HASHLEN) {
|
|
12
|
+
this.h = new Uint8Array(HASHLEN);
|
|
13
|
+
this.h.set(name);
|
|
14
|
+
}
|
|
15
|
+
else {
|
|
16
|
+
this.h = sha256(name);
|
|
17
|
+
}
|
|
18
|
+
this.ck = this.h.slice();
|
|
19
|
+
this.cipher.initializeKey(null);
|
|
20
|
+
}
|
|
21
|
+
mixKey(inputKeyMaterial) {
|
|
22
|
+
const [ck, tempK] = hkdf2(this.ck, inputKeyMaterial);
|
|
23
|
+
this.ck = ck;
|
|
24
|
+
this.cipher.initializeKey(tempK);
|
|
25
|
+
}
|
|
26
|
+
mixHash(data) {
|
|
27
|
+
this.h = sha256(concat(this.h, data));
|
|
28
|
+
}
|
|
29
|
+
handshakeHash() {
|
|
30
|
+
return this.h.slice();
|
|
31
|
+
}
|
|
32
|
+
encryptAndHash(plaintext) {
|
|
33
|
+
const ciphertext = this.cipher.encryptWithAd(this.h, plaintext);
|
|
34
|
+
this.mixHash(ciphertext);
|
|
35
|
+
return ciphertext;
|
|
36
|
+
}
|
|
37
|
+
decryptAndHash(ciphertext) {
|
|
38
|
+
const plaintext = this.cipher.decryptWithAd(this.h, ciphertext);
|
|
39
|
+
this.mixHash(ciphertext);
|
|
40
|
+
return plaintext;
|
|
41
|
+
}
|
|
42
|
+
split() {
|
|
43
|
+
const [k1, k2] = hkdf2(this.ck, EMPTY);
|
|
44
|
+
const c1 = new CipherState();
|
|
45
|
+
const c2 = new CipherState();
|
|
46
|
+
c1.initializeKey(k1);
|
|
47
|
+
c2.initializeKey(k2);
|
|
48
|
+
this.ck.fill(0);
|
|
49
|
+
this.cipher.clear();
|
|
50
|
+
return [c1, c2];
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
/** Noise spec §4.3 HKDF with two outputs: HMAC chain with 0x01 and 0x02 info bytes. */
|
|
54
|
+
function hkdf2(chainingKey, inputKeyMaterial) {
|
|
55
|
+
const tempKey = hmac(sha256, chainingKey, inputKeyMaterial);
|
|
56
|
+
const output1 = hmac(sha256, tempKey, new Uint8Array([0x01]));
|
|
57
|
+
const output2 = hmac(sha256, tempKey, concat(output1, new Uint8Array([0x02])));
|
|
58
|
+
tempKey.fill(0);
|
|
59
|
+
return [output1, output2];
|
|
60
|
+
}
|
|
61
|
+
export function concat(...parts) {
|
|
62
|
+
const total = parts.reduce((sum, part) => sum + part.byteLength, 0);
|
|
63
|
+
const out = new Uint8Array(total);
|
|
64
|
+
let offset = 0;
|
|
65
|
+
for (const part of parts) {
|
|
66
|
+
out.set(part, offset);
|
|
67
|
+
offset += part.byteLength;
|
|
68
|
+
}
|
|
69
|
+
return out;
|
|
70
|
+
}
|
|
71
|
+
//# sourceMappingURL=symmetric-state.js.map
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Authenticated application-frame framing over Noise transport messages.
|
|
3
|
+
*
|
|
4
|
+
* One application frame becomes one wire record. Every Noise transport message
|
|
5
|
+
* (chunk) carries, inside its authenticated plaintext, the frame kind, FIRST
|
|
6
|
+
* and FINAL flags, and the total frame length, so a relay cannot merge two
|
|
7
|
+
* records, split one across WebSocket messages, truncate a final chunk, or
|
|
8
|
+
* re-chunk a frame without an authentication failure or a framing violation.
|
|
9
|
+
* Transport AD is always empty, as in Noise §5.
|
|
10
|
+
*
|
|
11
|
+
* chunk plaintext = kind(1) || flags(1) || totalLength(u32 BE) || data
|
|
12
|
+
* wire record = for each chunk: ciphertextLength(u16 BE) || ciphertext
|
|
13
|
+
*/
|
|
14
|
+
import { CipherState } from "./noise/index.js";
|
|
15
|
+
export type FrameKind = "text" | "binary";
|
|
16
|
+
/** Largest data payload a single chunk carries. */
|
|
17
|
+
export declare const MAX_CHUNK_DATA_BYTES: number;
|
|
18
|
+
/** Largest application frame a record may carry; bounds allocation before decryption. */
|
|
19
|
+
export declare const MAX_FRAME_BYTES = 4294967295;
|
|
20
|
+
export declare function chunkCount(frameBytes: number): number;
|
|
21
|
+
/** Wire bytes a frame of `frameBytes` occupies once protected. */
|
|
22
|
+
export declare function protectedRecordByteLength(frameBytes: number): number;
|
|
23
|
+
export declare function encodeProtectedRecord(kind: FrameKind, frame: Uint8Array, sender: CipherState): Uint8Array;
|
|
24
|
+
export interface DecodedFrame {
|
|
25
|
+
kind: FrameKind;
|
|
26
|
+
data: Uint8Array;
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Decodes one wire record. Chunks are authenticated in order and the receive
|
|
30
|
+
* counter advances per authenticated chunk; any framing violation throws and
|
|
31
|
+
* nothing partial is returned. The caller must close the channel on error.
|
|
32
|
+
*/
|
|
33
|
+
export declare function decodeProtectedRecord(record: Uint8Array, receiver: CipherState): DecodedFrame;
|
|
34
|
+
//# sourceMappingURL=protected-records.d.ts.map
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Authenticated application-frame framing over Noise transport messages.
|
|
3
|
+
*
|
|
4
|
+
* One application frame becomes one wire record. Every Noise transport message
|
|
5
|
+
* (chunk) carries, inside its authenticated plaintext, the frame kind, FIRST
|
|
6
|
+
* and FINAL flags, and the total frame length, so a relay cannot merge two
|
|
7
|
+
* records, split one across WebSocket messages, truncate a final chunk, or
|
|
8
|
+
* re-chunk a frame without an authentication failure or a framing violation.
|
|
9
|
+
* Transport AD is always empty, as in Noise §5.
|
|
10
|
+
*
|
|
11
|
+
* chunk plaintext = kind(1) || flags(1) || totalLength(u32 BE) || data
|
|
12
|
+
* wire record = for each chunk: ciphertextLength(u16 BE) || ciphertext
|
|
13
|
+
*/
|
|
14
|
+
import { NoiseProtocolError, NOISE_MAX_MESSAGE_BYTES, NOISE_TAG_BYTES, } from "./noise/index.js";
|
|
15
|
+
const KIND_TEXT = 0x00;
|
|
16
|
+
const KIND_BINARY = 0x01;
|
|
17
|
+
const FLAG_FIRST = 0x01;
|
|
18
|
+
const FLAG_FINAL = 0x02;
|
|
19
|
+
const HEADER_BYTES = 6;
|
|
20
|
+
const LENGTH_PREFIX_BYTES = 2;
|
|
21
|
+
const EMPTY_AD = new Uint8Array(0);
|
|
22
|
+
/** Largest data payload a single chunk carries. */
|
|
23
|
+
export const MAX_CHUNK_DATA_BYTES = NOISE_MAX_MESSAGE_BYTES - NOISE_TAG_BYTES - HEADER_BYTES;
|
|
24
|
+
/** Largest application frame a record may carry; bounds allocation before decryption. */
|
|
25
|
+
export const MAX_FRAME_BYTES = 4294967295;
|
|
26
|
+
const CHUNK_OVERHEAD_BYTES = LENGTH_PREFIX_BYTES + HEADER_BYTES + NOISE_TAG_BYTES;
|
|
27
|
+
export function chunkCount(frameBytes) {
|
|
28
|
+
return Math.max(1, Math.ceil(frameBytes / MAX_CHUNK_DATA_BYTES));
|
|
29
|
+
}
|
|
30
|
+
/** Wire bytes a frame of `frameBytes` occupies once protected. */
|
|
31
|
+
export function protectedRecordByteLength(frameBytes) {
|
|
32
|
+
return frameBytes + chunkCount(frameBytes) * CHUNK_OVERHEAD_BYTES;
|
|
33
|
+
}
|
|
34
|
+
export function encodeProtectedRecord(kind, frame, sender) {
|
|
35
|
+
if (frame.byteLength > MAX_FRAME_BYTES)
|
|
36
|
+
throw new NoiseProtocolError("frame too large");
|
|
37
|
+
const chunks = chunkCount(frame.byteLength);
|
|
38
|
+
const out = new Uint8Array(protectedRecordByteLength(frame.byteLength));
|
|
39
|
+
let offset = 0;
|
|
40
|
+
for (let index = 0; index < chunks; index += 1) {
|
|
41
|
+
const start = index * MAX_CHUNK_DATA_BYTES;
|
|
42
|
+
const data = frame.subarray(start, Math.min(frame.byteLength, start + MAX_CHUNK_DATA_BYTES));
|
|
43
|
+
const plaintext = new Uint8Array(HEADER_BYTES + data.byteLength);
|
|
44
|
+
plaintext[0] = kind === "text" ? KIND_TEXT : KIND_BINARY;
|
|
45
|
+
plaintext[1] = (index === 0 ? FLAG_FIRST : 0) | (index === chunks - 1 ? FLAG_FINAL : 0);
|
|
46
|
+
writeU32(plaintext, 2, frame.byteLength);
|
|
47
|
+
plaintext.set(data, HEADER_BYTES);
|
|
48
|
+
const ciphertext = sender.encryptWithAd(EMPTY_AD, plaintext);
|
|
49
|
+
writeU16(out, offset, ciphertext.byteLength);
|
|
50
|
+
out.set(ciphertext, offset + LENGTH_PREFIX_BYTES);
|
|
51
|
+
offset += LENGTH_PREFIX_BYTES + ciphertext.byteLength;
|
|
52
|
+
}
|
|
53
|
+
return out;
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* Decodes one wire record. Chunks are authenticated in order and the receive
|
|
57
|
+
* counter advances per authenticated chunk; any framing violation throws and
|
|
58
|
+
* nothing partial is returned. The caller must close the channel on error.
|
|
59
|
+
*/
|
|
60
|
+
export function decodeProtectedRecord(record, receiver) {
|
|
61
|
+
let offset = 0;
|
|
62
|
+
let assembly = null;
|
|
63
|
+
while (offset < record.byteLength) {
|
|
64
|
+
if (assembly !== null && assembly.index >= assembly.expectedChunks) {
|
|
65
|
+
throw new NoiseProtocolError("unexpected chunk after final");
|
|
66
|
+
}
|
|
67
|
+
const { chunk, next } = readChunk(record, offset, receiver);
|
|
68
|
+
offset = next;
|
|
69
|
+
assembly =
|
|
70
|
+
assembly === null
|
|
71
|
+
? startAssembly(chunk, record.byteLength)
|
|
72
|
+
: continueAssembly(assembly, chunk);
|
|
73
|
+
}
|
|
74
|
+
if (assembly === null)
|
|
75
|
+
throw new NoiseProtocolError("empty record");
|
|
76
|
+
if (assembly.index !== assembly.expectedChunks || assembly.written !== assembly.totalLength) {
|
|
77
|
+
throw new NoiseProtocolError("record incomplete");
|
|
78
|
+
}
|
|
79
|
+
return { kind: assembly.kind, data: assembly.frame };
|
|
80
|
+
}
|
|
81
|
+
function readChunk(record, offset, receiver) {
|
|
82
|
+
if (record.byteLength - offset < LENGTH_PREFIX_BYTES) {
|
|
83
|
+
throw new NoiseProtocolError("record truncated");
|
|
84
|
+
}
|
|
85
|
+
const ciphertextLength = readU16(record, offset);
|
|
86
|
+
const start = offset + LENGTH_PREFIX_BYTES;
|
|
87
|
+
if (ciphertextLength < NOISE_TAG_BYTES + HEADER_BYTES ||
|
|
88
|
+
ciphertextLength > NOISE_MAX_MESSAGE_BYTES) {
|
|
89
|
+
throw new NoiseProtocolError("invalid chunk length");
|
|
90
|
+
}
|
|
91
|
+
if (record.byteLength - start < ciphertextLength) {
|
|
92
|
+
throw new NoiseProtocolError("record truncated");
|
|
93
|
+
}
|
|
94
|
+
const plaintext = receiver.decryptWithAd(EMPTY_AD, record.subarray(start, start + ciphertextLength));
|
|
95
|
+
const flags = plaintext[1];
|
|
96
|
+
if ((flags & ~(FLAG_FIRST | FLAG_FINAL)) !== 0) {
|
|
97
|
+
throw new NoiseProtocolError("reserved flag set");
|
|
98
|
+
}
|
|
99
|
+
return {
|
|
100
|
+
chunk: {
|
|
101
|
+
kind: decodeKind(plaintext[0]),
|
|
102
|
+
isFirst: (flags & FLAG_FIRST) !== 0,
|
|
103
|
+
isFinal: (flags & FLAG_FINAL) !== 0,
|
|
104
|
+
totalLength: readU32(plaintext, 2),
|
|
105
|
+
data: plaintext.subarray(HEADER_BYTES),
|
|
106
|
+
},
|
|
107
|
+
next: start + ciphertextLength,
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
function startAssembly(chunk, recordByteLength) {
|
|
111
|
+
if (!chunk.isFirst)
|
|
112
|
+
throw new NoiseProtocolError("record does not start with FIRST");
|
|
113
|
+
// The claimed frame length fixes the record's exact wire length. Check it
|
|
114
|
+
// before allocating so a tiny authenticated header cannot demand gigabytes.
|
|
115
|
+
if (protectedRecordByteLength(chunk.totalLength) !== recordByteLength) {
|
|
116
|
+
throw new NoiseProtocolError("record length mismatch");
|
|
117
|
+
}
|
|
118
|
+
const assembly = {
|
|
119
|
+
kind: chunk.kind,
|
|
120
|
+
totalLength: chunk.totalLength,
|
|
121
|
+
expectedChunks: chunkCount(chunk.totalLength),
|
|
122
|
+
frame: new Uint8Array(chunk.totalLength),
|
|
123
|
+
written: 0,
|
|
124
|
+
index: 0,
|
|
125
|
+
};
|
|
126
|
+
return appendChunk(assembly, chunk);
|
|
127
|
+
}
|
|
128
|
+
function continueAssembly(assembly, chunk) {
|
|
129
|
+
if (chunk.isFirst)
|
|
130
|
+
throw new NoiseProtocolError("FIRST repeated inside record");
|
|
131
|
+
if (chunk.kind !== assembly.kind) {
|
|
132
|
+
throw new NoiseProtocolError("frame kind changed inside record");
|
|
133
|
+
}
|
|
134
|
+
if (chunk.totalLength !== assembly.totalLength) {
|
|
135
|
+
throw new NoiseProtocolError("frame length changed inside record");
|
|
136
|
+
}
|
|
137
|
+
return appendChunk(assembly, chunk);
|
|
138
|
+
}
|
|
139
|
+
function appendChunk(assembly, chunk) {
|
|
140
|
+
const isLastExpected = assembly.index === assembly.expectedChunks - 1;
|
|
141
|
+
if (chunk.isFinal !== isLastExpected)
|
|
142
|
+
throw new NoiseProtocolError("FINAL flag mismatch");
|
|
143
|
+
const expectedData = isLastExpected
|
|
144
|
+
? assembly.totalLength - assembly.index * MAX_CHUNK_DATA_BYTES
|
|
145
|
+
: MAX_CHUNK_DATA_BYTES;
|
|
146
|
+
if (chunk.data.byteLength !== expectedData)
|
|
147
|
+
throw new NoiseProtocolError("chunk size mismatch");
|
|
148
|
+
assembly.frame.set(chunk.data, assembly.written);
|
|
149
|
+
assembly.written += chunk.data.byteLength;
|
|
150
|
+
assembly.index += 1;
|
|
151
|
+
return assembly;
|
|
152
|
+
}
|
|
153
|
+
function decodeKind(byte) {
|
|
154
|
+
if (byte === KIND_TEXT)
|
|
155
|
+
return "text";
|
|
156
|
+
if (byte === KIND_BINARY)
|
|
157
|
+
return "binary";
|
|
158
|
+
throw new NoiseProtocolError("invalid frame kind");
|
|
159
|
+
}
|
|
160
|
+
function writeU16(target, offset, value) {
|
|
161
|
+
target[offset] = (value >>> 8) & 0xff;
|
|
162
|
+
target[offset + 1] = value & 0xff;
|
|
163
|
+
}
|
|
164
|
+
function readU16(source, offset) {
|
|
165
|
+
return (source[offset] << 8) | source[offset + 1];
|
|
166
|
+
}
|
|
167
|
+
function writeU32(target, offset, value) {
|
|
168
|
+
target[offset] = (value >>> 24) & 0xff;
|
|
169
|
+
target[offset + 1] = (value >>> 16) & 0xff;
|
|
170
|
+
target[offset + 2] = (value >>> 8) & 0xff;
|
|
171
|
+
target[offset + 3] = value & 0xff;
|
|
172
|
+
}
|
|
173
|
+
function readU32(source, offset) {
|
|
174
|
+
return (((source[offset] << 24) >>> 0) +
|
|
175
|
+
(source[offset + 1] << 16) +
|
|
176
|
+
(source[offset + 2] << 8) +
|
|
177
|
+
source[offset + 3]);
|
|
178
|
+
}
|
|
179
|
+
//# sourceMappingURL=protected-records.js.map
|