lemon-tls 0.2.2 → 0.4.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.
@@ -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
+ };