lemon-tls 0.2.1 → 0.3.0
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 +258 -203
- package/index.d.ts +145 -14
- package/index.js +33 -6
- package/package.json +3 -10
- package/src/compat.js +290 -31
- package/src/crypto.js +139 -8
- package/src/dtls_session.js +865 -0
- package/src/dtls_socket.js +263 -0
- package/src/record.js +894 -65
- package/src/session/message.js +33 -5
- package/src/session/ticket.js +185 -0
- package/src/tls_session.js +945 -150
- package/src/tls_socket.js +815 -249
- package/src/wire.js +167 -11
package/src/session/message.js
CHANGED
|
@@ -68,6 +68,15 @@ function normalize_hello(hello) {
|
|
|
68
68
|
out.heartbeat = value;
|
|
69
69
|
} else if (name === 'USE_SRTP') {
|
|
70
70
|
out.use_srtp = value;
|
|
71
|
+
} else if (name === 'SESSION_TICKET') {
|
|
72
|
+
// RFC 5077: opaque ticket bytes (TLS 1.2)
|
|
73
|
+
// Empty in ClientHello = client supports tickets / non-empty = resume using this ticket
|
|
74
|
+
// Empty in ServerHello = server will send NewSessionTicket
|
|
75
|
+
out.session_ticket = value;
|
|
76
|
+
out.session_ticket_supported = true;
|
|
77
|
+
} else if (name === 'EXTENDED_MASTER_SECRET') {
|
|
78
|
+
// RFC 7627: presence signals EMS support
|
|
79
|
+
out.extended_master_secret = true;
|
|
71
80
|
} else {
|
|
72
81
|
if (!('unknown' in out)) out.unknown = [];
|
|
73
82
|
out.unknown.push(e);
|
|
@@ -97,10 +106,12 @@ function build_tls_message(params) {
|
|
|
97
106
|
|
|
98
107
|
if (params.type == 'server_hello') {
|
|
99
108
|
type = wire.TLS_MESSAGE_TYPE.SERVER_HELLO;
|
|
100
|
-
|
|
109
|
+
params.kind = 'server';
|
|
110
|
+
body = wire.build_hello(params);
|
|
101
111
|
} else if (params.type == 'client_hello') {
|
|
102
112
|
type = wire.TLS_MESSAGE_TYPE.CLIENT_HELLO;
|
|
103
|
-
|
|
113
|
+
params.kind = 'client';
|
|
114
|
+
body = wire.build_hello(params);
|
|
104
115
|
} else if (params.type == 'server_key_exchange') {
|
|
105
116
|
type = wire.TLS_MESSAGE_TYPE.SERVER_KEY_EXCHANGE;
|
|
106
117
|
body = wire.build_server_key_exchange_ecdhe(params);
|
|
@@ -131,6 +142,12 @@ function build_tls_message(params) {
|
|
|
131
142
|
} else if (params.type == 'hello_retry_request') {
|
|
132
143
|
type = wire.TLS_MESSAGE_TYPE.SERVER_HELLO; // HRR uses ServerHello type with magic random
|
|
133
144
|
body = wire.build_hello_retry_request(params);
|
|
145
|
+
} else if (params.type == 'new_session_ticket_tls12') {
|
|
146
|
+
type = wire.TLS_MESSAGE_TYPE.NEW_SESSION_TICKET;
|
|
147
|
+
body = wire.build_new_session_ticket_tls12(params);
|
|
148
|
+
} else if (params.type == 'new_session_ticket') {
|
|
149
|
+
type = wire.TLS_MESSAGE_TYPE.NEW_SESSION_TICKET;
|
|
150
|
+
body = wire.build_new_session_ticket(params);
|
|
134
151
|
}
|
|
135
152
|
|
|
136
153
|
return wire.build_message(type, body);
|
|
@@ -139,13 +156,17 @@ function build_tls_message(params) {
|
|
|
139
156
|
|
|
140
157
|
/**
|
|
141
158
|
* Parse a raw TLS handshake message into a typed object.
|
|
159
|
+
* @param {Uint8Array} data — handshake message bytes
|
|
160
|
+
* @param {number} [negotiatedVersion] — optional; 0x0303 for TLS 1.2, 0x0304 for TLS 1.3.
|
|
161
|
+
* Needed to disambiguate NewSessionTicket wire format.
|
|
142
162
|
*/
|
|
143
|
-
function parse_tls_message(data) {
|
|
163
|
+
function parse_tls_message(data, negotiatedVersion) {
|
|
144
164
|
let out = {};
|
|
145
165
|
let message = wire.parse_message(data);
|
|
146
166
|
|
|
147
167
|
if (message.type == wire.TLS_MESSAGE_TYPE.CLIENT_HELLO || message.type == wire.TLS_MESSAGE_TYPE.SERVER_HELLO) {
|
|
148
|
-
let
|
|
168
|
+
let kind = (message.type == wire.TLS_MESSAGE_TYPE.CLIENT_HELLO) ? 'client' : 'server';
|
|
169
|
+
let hello = wire.parse_hello({ kind: kind, body: message.body });
|
|
149
170
|
out = normalize_hello(hello);
|
|
150
171
|
|
|
151
172
|
} else if (message.type == wire.TLS_MESSAGE_TYPE.SERVER_KEY_EXCHANGE) {
|
|
@@ -180,7 +201,14 @@ function parse_tls_message(data) {
|
|
|
180
201
|
out.body = message.body;
|
|
181
202
|
|
|
182
203
|
} else if (message.type == wire.TLS_MESSAGE_TYPE.NEW_SESSION_TICKET) {
|
|
183
|
-
|
|
204
|
+
// TLS 1.2 and 1.3 have different wire formats for this message.
|
|
205
|
+
// TLS 1.3: ticket_lifetime | ticket_age_add | ticket_nonce | ticket | extensions
|
|
206
|
+
// TLS 1.2: ticket_lifetime_hint | ticket (RFC 5077)
|
|
207
|
+
if (negotiatedVersion === 0x0303) {
|
|
208
|
+
out = wire.parse_new_session_ticket_tls12(message.body);
|
|
209
|
+
} else {
|
|
210
|
+
out = wire.parse_new_session_ticket(message.body);
|
|
211
|
+
}
|
|
184
212
|
out.type = 'new_session_ticket';
|
|
185
213
|
|
|
186
214
|
} else if (message.type == wire.TLS_MESSAGE_TYPE.KEY_UPDATE) {
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Session ticket / session blob encoding & decoding.
|
|
3
|
+
*
|
|
4
|
+
* Two distinct blob types:
|
|
5
|
+
*
|
|
6
|
+
* 1. SERVER blob (encrypted) — issued by server to client (TLS 1.3 NewSessionTicket,
|
|
7
|
+
* TLS 1.2 NewSessionTicket), or to user's store (TLS 1.2 Session IDs via 'newSession' event).
|
|
8
|
+
* Client/store returns it back; server decrypts with its ticketKeys.
|
|
9
|
+
*
|
|
10
|
+
* Format: key_name(16) | IV(12) | Ciphertext(AES-256-GCM) | Tag(16)
|
|
11
|
+
* ticketKeys layout: [0:16]=key_name, [16:48]=AES-256-GCM key
|
|
12
|
+
*
|
|
13
|
+
* 2. CLIENT blob (plaintext) — returned by TLSSocket to the user via 'session' event;
|
|
14
|
+
* user passes it back in tls.connect({ session }). JSON-encoded, not encrypted —
|
|
15
|
+
* user is responsible for secure storage (same convention as Node.js).
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import * as crypto from 'node:crypto';
|
|
19
|
+
import { uint8Equal } from '../utils.js';
|
|
20
|
+
|
|
21
|
+
/* =========================================================================
|
|
22
|
+
* SERVER-SIDE ENCRYPTED BLOB
|
|
23
|
+
* ========================================================================= */
|
|
24
|
+
|
|
25
|
+
const KEY_NAME_LEN = 16;
|
|
26
|
+
const IV_LEN = 12;
|
|
27
|
+
const TAG_LEN = 16;
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Split ticketKeys (48 bytes) into key_name (16) + aes key (32).
|
|
31
|
+
* Throws if ticketKeys is malformed.
|
|
32
|
+
*/
|
|
33
|
+
function split_ticket_keys(ticketKeys) {
|
|
34
|
+
if (!ticketKeys) throw new Error('ticketKeys is required');
|
|
35
|
+
let buf = Buffer.isBuffer(ticketKeys) ? ticketKeys : Buffer.from(ticketKeys);
|
|
36
|
+
if (buf.length !== 48) throw new Error('ticketKeys must be exactly 48 bytes');
|
|
37
|
+
return {
|
|
38
|
+
key_name: buf.slice(0, 16),
|
|
39
|
+
aes_key: buf.slice(16, 48),
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Encrypt a server-side session state into an opaque blob.
|
|
45
|
+
*
|
|
46
|
+
* @param {Object} state — session state (version, cipher, master_secret, ...)
|
|
47
|
+
* @param {Buffer} ticketKeys — 48 bytes (key_name + aes_key)
|
|
48
|
+
* @returns {Uint8Array} — key_name(16) | IV(12) | CT | Tag(16)
|
|
49
|
+
*/
|
|
50
|
+
function encrypt_session_blob(state, ticketKeys) {
|
|
51
|
+
let { key_name, aes_key } = split_ticket_keys(ticketKeys);
|
|
52
|
+
|
|
53
|
+
// Serialize state → JSON with Uint8Array fields base64-encoded
|
|
54
|
+
let serialized = Buffer.from(JSON.stringify(serialize_state(state)));
|
|
55
|
+
|
|
56
|
+
let iv = crypto.randomBytes(IV_LEN);
|
|
57
|
+
let cipher = crypto.createCipheriv('aes-256-gcm', aes_key, iv);
|
|
58
|
+
let ct = cipher.update(serialized);
|
|
59
|
+
cipher.final();
|
|
60
|
+
let tag = cipher.getAuthTag();
|
|
61
|
+
|
|
62
|
+
let out = Buffer.concat([key_name, iv, ct, tag]);
|
|
63
|
+
return new Uint8Array(out);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Decrypt a server-side session blob.
|
|
68
|
+
*
|
|
69
|
+
* @param {Uint8Array} blob — encrypted blob
|
|
70
|
+
* @param {Buffer} ticketKeys — 48 bytes. Must match the key_name embedded in blob.
|
|
71
|
+
* @returns {Object|null} — state, or null if decryption failed (wrong key_name, tampered data, etc).
|
|
72
|
+
*/
|
|
73
|
+
function decrypt_session_blob(blob, ticketKeys) {
|
|
74
|
+
try {
|
|
75
|
+
if (!blob || blob.length < KEY_NAME_LEN + IV_LEN + TAG_LEN) return null;
|
|
76
|
+
let buf = Buffer.isBuffer(blob) ? blob : Buffer.from(blob);
|
|
77
|
+
|
|
78
|
+
let blob_key_name = buf.slice(0, KEY_NAME_LEN);
|
|
79
|
+
let iv = buf.slice(KEY_NAME_LEN, KEY_NAME_LEN + IV_LEN);
|
|
80
|
+
let tag = buf.slice(buf.length - TAG_LEN);
|
|
81
|
+
let ct = buf.slice(KEY_NAME_LEN + IV_LEN, buf.length - TAG_LEN);
|
|
82
|
+
|
|
83
|
+
let { key_name, aes_key } = split_ticket_keys(ticketKeys);
|
|
84
|
+
|
|
85
|
+
// Verify key_name matches (allows rotation — caller may try multiple keys)
|
|
86
|
+
if (!uint8Equal(blob_key_name, key_name)) return null;
|
|
87
|
+
|
|
88
|
+
let decipher = crypto.createDecipheriv('aes-256-gcm', aes_key, iv);
|
|
89
|
+
decipher.setAuthTag(tag);
|
|
90
|
+
let pt = decipher.update(ct);
|
|
91
|
+
decipher.final(); // throws on tag mismatch
|
|
92
|
+
|
|
93
|
+
let state = deserialize_state(JSON.parse(pt.toString('utf8')));
|
|
94
|
+
return state;
|
|
95
|
+
} catch (e) {
|
|
96
|
+
return null;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/* =========================================================================
|
|
101
|
+
* CLIENT-SIDE PLAINTEXT BLOB
|
|
102
|
+
* ========================================================================= */
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Encode a client-side session blob. JSON-serialized, NOT encrypted.
|
|
106
|
+
* User is responsible for secure storage (this contains the master_secret).
|
|
107
|
+
*
|
|
108
|
+
* @param {Object} state
|
|
109
|
+
* @returns {Uint8Array} — utf-8 encoded JSON
|
|
110
|
+
*/
|
|
111
|
+
function encode_client_session(state) {
|
|
112
|
+
let serialized = JSON.stringify(serialize_state(state));
|
|
113
|
+
return new Uint8Array(Buffer.from(serialized, 'utf8'));
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Decode a client-side session blob.
|
|
118
|
+
*
|
|
119
|
+
* @param {Uint8Array|Buffer} blob
|
|
120
|
+
* @returns {Object|null}
|
|
121
|
+
*/
|
|
122
|
+
function decode_client_session(blob) {
|
|
123
|
+
try {
|
|
124
|
+
if (!blob || blob.length === 0) return null;
|
|
125
|
+
let buf = Buffer.isBuffer(blob) ? blob : Buffer.from(blob);
|
|
126
|
+
return deserialize_state(JSON.parse(buf.toString('utf8')));
|
|
127
|
+
} catch (e) {
|
|
128
|
+
return null;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/* =========================================================================
|
|
133
|
+
* SERIALIZATION HELPERS
|
|
134
|
+
* ========================================================================= */
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Convert state object to JSON-safe form.
|
|
138
|
+
* Uint8Array/Buffer fields → { $b: base64 }
|
|
139
|
+
*/
|
|
140
|
+
function serialize_state(state) {
|
|
141
|
+
let out = {};
|
|
142
|
+
for (let k in state) {
|
|
143
|
+
let v = state[k];
|
|
144
|
+
if (v == null) {
|
|
145
|
+
out[k] = null;
|
|
146
|
+
} else if (v instanceof Uint8Array || Buffer.isBuffer(v)) {
|
|
147
|
+
out[k] = { $b: Buffer.from(v).toString('base64') };
|
|
148
|
+
} else if (typeof v === 'object' && !Array.isArray(v)) {
|
|
149
|
+
out[k] = serialize_state(v);
|
|
150
|
+
} else {
|
|
151
|
+
out[k] = v;
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
return out;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Convert JSON-decoded form back to state object.
|
|
159
|
+
* { $b: base64 } → Uint8Array
|
|
160
|
+
*/
|
|
161
|
+
function deserialize_state(obj) {
|
|
162
|
+
if (obj == null) return null;
|
|
163
|
+
let out = {};
|
|
164
|
+
for (let k in obj) {
|
|
165
|
+
let v = obj[k];
|
|
166
|
+
if (v == null) {
|
|
167
|
+
out[k] = null;
|
|
168
|
+
} else if (typeof v === 'object' && typeof v.$b === 'string' && Object.keys(v).length === 1) {
|
|
169
|
+
out[k] = new Uint8Array(Buffer.from(v.$b, 'base64'));
|
|
170
|
+
} else if (typeof v === 'object' && !Array.isArray(v)) {
|
|
171
|
+
out[k] = deserialize_state(v);
|
|
172
|
+
} else {
|
|
173
|
+
out[k] = v;
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
return out;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
export {
|
|
180
|
+
encrypt_session_blob,
|
|
181
|
+
decrypt_session_blob,
|
|
182
|
+
encode_client_session,
|
|
183
|
+
decode_client_session,
|
|
184
|
+
split_ticket_keys,
|
|
185
|
+
};
|