lemon-tls 0.6.0 → 0.7.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/package.json +2 -7
- package/src/crypto.js +14 -3
- package/src/dtls_session.js +415 -35
- package/src/record.js +138 -22
- package/src/session/message.js +5 -1
- package/src/session/ticket.js +6 -3
- package/src/tls_session.js +188 -32
- package/src/tls_socket.js +0 -1
- package/src/utils.js +7 -1
- package/src/wire.js +72 -4
package/src/record.js
CHANGED
|
@@ -16,6 +16,7 @@ import {
|
|
|
16
16
|
w_u8,
|
|
17
17
|
w_u16,
|
|
18
18
|
w_u48,
|
|
19
|
+
w_u64,
|
|
19
20
|
} from './wire.js';
|
|
20
21
|
|
|
21
22
|
// ===================== AEAD algorithm resolution =====================
|
|
@@ -589,6 +590,11 @@ function writeRecord(transport, type, payload, version) {
|
|
|
589
590
|
// readU16, readU48 return value only (no offset tracking), unlike wire.js r_u16 which returns [value, offset]
|
|
590
591
|
|
|
591
592
|
function readU16(buf, off) { return ((buf[off] << 8) | buf[off+1]) >>> 0; }
|
|
593
|
+
function readU64(buf, off) {
|
|
594
|
+
let hi = ((buf[off] << 24) | (buf[off+1] << 16) | (buf[off+2] << 8) | buf[off+3]) >>> 0;
|
|
595
|
+
let lo = ((buf[off+4] << 24) | (buf[off+5] << 16) | (buf[off+6] << 8) | buf[off+7]) >>> 0;
|
|
596
|
+
return hi * 0x100000000 + lo;
|
|
597
|
+
}
|
|
592
598
|
function readU48(buf, off) {
|
|
593
599
|
let hi = ((buf[off] << 8) | buf[off+1]) >>> 0;
|
|
594
600
|
let lo = ((buf[off+2] << 24) | (buf[off+3] << 16) | (buf[off+4] << 8) | buf[off+5]) >>> 0;
|
|
@@ -800,10 +806,35 @@ function isUnifiedHdr(b) { return (b & 0xE0) === UNIFIED_HDR_FIXED; }
|
|
|
800
806
|
* mask = AES-ECB(snKey, ciphertext[0..15])
|
|
801
807
|
* result = rnBytes XOR mask[0..len-1]
|
|
802
808
|
*/
|
|
803
|
-
function maskRecordNumber(snKey, rnBytes, ciphertext) {
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
809
|
+
function maskRecordNumber(snKey, rnBytes, ciphertext, algo) {
|
|
810
|
+
// RFC 9147 §4.2.3: "This procedure requires the ciphertext length to be at
|
|
811
|
+
// least 16 bytes. Receivers MUST reject shorter records as if they had
|
|
812
|
+
// failed deprotection." Zero-padding a short sample instead (the previous
|
|
813
|
+
// behaviour) both diverges from the spec and yields a predictable mask.
|
|
814
|
+
// Senders must pad short plaintexts; with a 16-byte tag that never happens
|
|
815
|
+
// for the AEADs we support, so this is a guard, not a code path.
|
|
816
|
+
if (!ciphertext || ciphertext.length < 16) return null;
|
|
817
|
+
|
|
818
|
+
let mask;
|
|
819
|
+
if (algo === 'chacha20-poly1305') {
|
|
820
|
+
// Mask = ChaCha20(sn_key, Ciphertext[0..3], Ciphertext[4..15])
|
|
821
|
+
// — first 4 bytes are the block counter, next 12 the nonce. Node's
|
|
822
|
+
// 'chacha20' cipher takes exactly that as a 16-byte IV, so the sample is
|
|
823
|
+
// passed through unchanged and we encrypt a zero block to read off the
|
|
824
|
+
// keystream.
|
|
825
|
+
//
|
|
826
|
+
// The AES branch below is well covered by the DTLS 1.3 suites we
|
|
827
|
+
// negotiate in practice; this branch is not, so cross-check it against a
|
|
828
|
+
// BoringSSL peer before trusting it — in particular the counter's byte
|
|
829
|
+
// order, which the RFC states as a byte range and OpenSSL interprets as a
|
|
830
|
+
// little-endian u32.
|
|
831
|
+
let iv = Buffer.from(ciphertext.subarray(0, 16));
|
|
832
|
+
let c = crypto.createCipheriv('chacha20', snKey, iv);
|
|
833
|
+
mask = new Uint8Array(c.update(Buffer.alloc(16)));
|
|
834
|
+
} else {
|
|
835
|
+
mask = aesEcbEncrypt(snKey, ciphertext.subarray(0, 16));
|
|
836
|
+
}
|
|
837
|
+
|
|
807
838
|
let out = new Uint8Array(rnBytes.length);
|
|
808
839
|
for (let i = 0; i < rnBytes.length; i++) out[i] = rnBytes[i] ^ mask[i];
|
|
809
840
|
return out;
|
|
@@ -864,7 +895,32 @@ function buildEncryptedDtls13(innerType, plaintext, seq, epoch, keys) {
|
|
|
864
895
|
ciphertext.set(new Uint8Array(tag), ct.length);
|
|
865
896
|
|
|
866
897
|
// Encrypt record number
|
|
867
|
-
let encRn = maskRecordNumber(keys.snKey, rn, ciphertext);
|
|
898
|
+
let encRn = maskRecordNumber(keys.snKey, rn, ciphertext, algo);
|
|
899
|
+
|
|
900
|
+
// ── DIAGNOSTIC (LEMON_DEBUG=1 / WEBRTC_DEBUG=1) ──────────────────────
|
|
901
|
+
// Everything a peer needs to PARSE this record, printed as we build it.
|
|
902
|
+
// A decode_error alert means the peer could not make structural sense of
|
|
903
|
+
// what arrived, so the fields to compare are the info byte (which
|
|
904
|
+
// declares whether a CID, a length and a 16-bit sequence number are
|
|
905
|
+
// present), the sequence number before and after masking, and the
|
|
906
|
+
// declared length against the actual ciphertext size. If two records
|
|
907
|
+
// are accepted and the third is not, the difference between them shows
|
|
908
|
+
// up here.
|
|
909
|
+
try {
|
|
910
|
+
if (typeof process !== 'undefined' && process.env &&
|
|
911
|
+
(process.env.LEMON_DEBUG === '1' || process.env.WEBRTC_DEBUG === '1')) {
|
|
912
|
+
var _h = function (b) { return ('0' + (b & 0xFF).toString(16)).slice(-2); };
|
|
913
|
+
console.log('[lemon-rec] TX info=0x' + _h(info) +
|
|
914
|
+
' [C=' + ((info >> 4) & 1) + ' S=' + ((info >> 3) & 1) +
|
|
915
|
+
' L=' + ((info >> 2) & 1) + ' epoch=' + (info & 0x03) + ']' +
|
|
916
|
+
' seqPlain=' + _h(rn[0]) + _h(rn[1]) +
|
|
917
|
+
' seqMasked=' + _h(encRn[0]) + _h(encRn[1]) +
|
|
918
|
+
' declaredLen=' + ciphertext.length +
|
|
919
|
+
' aadLen=' + (aad ? aad.length : 'null') +
|
|
920
|
+
' snKey=' + (keys.snKey ? 'set' : 'MISSING'));
|
|
921
|
+
}
|
|
922
|
+
} catch (_e) {}
|
|
923
|
+
// ─────────────────────────────────────────────────────────────────────
|
|
868
924
|
|
|
869
925
|
// Assemble: info(1) + encrypted_rn(2) + length(2) + ciphertext
|
|
870
926
|
let record = new Uint8Array(5 + ciphertext.length);
|
|
@@ -909,13 +965,46 @@ function reconstructDtlsSeq(truncated, bits, maxSeq) {
|
|
|
909
965
|
}
|
|
910
966
|
|
|
911
967
|
|
|
968
|
+
/**
|
|
969
|
+
* Resolve the full epoch from the 2 bits carried in the unified header.
|
|
970
|
+
*
|
|
971
|
+
* RFC 9147 §4.2.2: the header holds only the low 2 bits of the epoch, so
|
|
972
|
+
* epoch 4 is indistinguishable from epoch 0 on the wire, 5 from 1, and so on.
|
|
973
|
+
* The spec's rule is to use "the most recent past epoch which has matching
|
|
974
|
+
* bits" — i.e. among the epochs we actually hold read keys for, the highest
|
|
975
|
+
* one whose low 2 bits match.
|
|
976
|
+
*
|
|
977
|
+
* Until KeyUpdate existed, only epochs 0/2/3 were ever in play and each had a
|
|
978
|
+
* unique bit pattern, so indexing the key table directly by the 2 bits worked
|
|
979
|
+
* by accident. The first KeyUpdate moves a sender to epoch 4, whose bits are
|
|
980
|
+
* 00 — and a lookup by bits then hands back the epoch-0 (plaintext) entry.
|
|
981
|
+
*
|
|
982
|
+
* @param epochBits the 2-bit value from the header
|
|
983
|
+
* @param keysByEpoch object keyed by FULL epoch number
|
|
984
|
+
* @returns the full epoch number, or null if we hold no matching keys
|
|
985
|
+
*/
|
|
986
|
+
function resolveReadEpoch(epochBits, keysByEpoch) {
|
|
987
|
+
if (!keysByEpoch) return null;
|
|
988
|
+
let best = null;
|
|
989
|
+
for (let k in keysByEpoch) {
|
|
990
|
+
if (!Object.prototype.hasOwnProperty.call(keysByEpoch, k)) continue;
|
|
991
|
+
let e = Number(k);
|
|
992
|
+
if (!Number.isFinite(e)) continue;
|
|
993
|
+
if ((e & 0x03) !== (epochBits & 0x03)) continue;
|
|
994
|
+
if (keysByEpoch[k] == null) continue;
|
|
995
|
+
if (best === null || e > best) best = e;
|
|
996
|
+
}
|
|
997
|
+
return best;
|
|
998
|
+
}
|
|
999
|
+
|
|
1000
|
+
|
|
912
1001
|
/**
|
|
913
1002
|
* Decrypt a DTLS 1.3 encrypted record.
|
|
914
1003
|
* data: full record bytes (starting with unified header byte)
|
|
915
1004
|
* keys: { key, iv, snKey, algo?, maxReadSeq? }
|
|
916
1005
|
* Returns { epoch, seq, type, content } or null on failure.
|
|
917
1006
|
*/
|
|
918
|
-
function decryptEncryptedDtls13(data, keys) {
|
|
1007
|
+
function decryptEncryptedDtls13(data, keys, fullEpoch) {
|
|
919
1008
|
if (data.length < 1) return null;
|
|
920
1009
|
|
|
921
1010
|
let hdr = parseUnifiedHdr(data[0]);
|
|
@@ -940,8 +1029,16 @@ function decryptEncryptedDtls13(data, keys) {
|
|
|
940
1029
|
if (off + ctLen > data.length) return null;
|
|
941
1030
|
let ciphertext = data.slice(off, off + ctLen);
|
|
942
1031
|
|
|
943
|
-
//
|
|
944
|
-
|
|
1032
|
+
// Resolve the AEAD before unmasking: the record-number mask is built with
|
|
1033
|
+
// the same primitive as the AEAD (AES-ECB vs ChaCha20), so `algo` is needed
|
|
1034
|
+
// here, not just at deprotection time further down.
|
|
1035
|
+
let algo = keys.algo || (keys.key.length === 32 ? 'aes-256-gcm' : 'aes-128-gcm');
|
|
1036
|
+
let isChaCha = algo === 'chacha20-poly1305';
|
|
1037
|
+
|
|
1038
|
+
// Decrypt record number. Returns null for a ciphertext under 16 bytes, which
|
|
1039
|
+
// RFC 9147 §4.2.3 requires us to reject as a deprotection failure.
|
|
1040
|
+
let rn = maskRecordNumber(keys.snKey, encRn, ciphertext, algo);
|
|
1041
|
+
if (rn === null) return null;
|
|
945
1042
|
let truncated = hdr.seqLen2 ? ((rn[0] << 8) | rn[1]) : rn[0];
|
|
946
1043
|
|
|
947
1044
|
// RFC 9147 §4.2.2: the wire carries only the low 8 or 16 bits of the record
|
|
@@ -968,10 +1065,6 @@ function decryptEncryptedDtls13(data, keys) {
|
|
|
968
1065
|
aad[1 + rnLen + 1] = ctLen & 0xFF;
|
|
969
1066
|
}
|
|
970
1067
|
|
|
971
|
-
let algo = keys.algo || (keys.key.length === 32 ? 'aes-256-gcm' : 'aes-128-gcm');
|
|
972
|
-
let isChaCha = algo === 'chacha20-poly1305';
|
|
973
|
-
|
|
974
|
-
if (ciphertext.length < 16) return null;
|
|
975
1068
|
let ct = ciphertext.subarray(0, ciphertext.length - 16);
|
|
976
1069
|
let tag = ciphertext.subarray(ciphertext.length - 16);
|
|
977
1070
|
|
|
@@ -1038,7 +1131,9 @@ function decryptEncryptedDtls13(data, keys) {
|
|
|
1038
1131
|
}
|
|
1039
1132
|
|
|
1040
1133
|
return {
|
|
1041
|
-
epoch
|
|
1134
|
+
// The caller resolves the full epoch (resolveReadEpoch); hdr.epoch is
|
|
1135
|
+
// only its low 2 bits and must never escape this function as an epoch.
|
|
1136
|
+
epoch: (fullEpoch === undefined || fullEpoch === null) ? hdr.epoch : fullEpoch,
|
|
1042
1137
|
seq: seq,
|
|
1043
1138
|
type: inner.type,
|
|
1044
1139
|
content: inner.content,
|
|
@@ -1066,7 +1161,9 @@ function parseDtlsDatagram(data, keysByEpoch) {
|
|
|
1066
1161
|
|
|
1067
1162
|
if (isUnifiedHdr(first)) {
|
|
1068
1163
|
let hdr = parseUnifiedHdr(first);
|
|
1069
|
-
|
|
1164
|
+
// Header carries 2 epoch bits; map them back to a real epoch first.
|
|
1165
|
+
let fullEpoch = resolveReadEpoch(hdr.epoch, keysByEpoch);
|
|
1166
|
+
let keys = (fullEpoch === null) ? null : keysByEpoch[fullEpoch];
|
|
1070
1167
|
|
|
1071
1168
|
if (!keys) {
|
|
1072
1169
|
// No keys for this epoch YET. Do not discard the record: a DTLS peer
|
|
@@ -1090,8 +1187,11 @@ function parseDtlsDatagram(data, keysByEpoch) {
|
|
|
1090
1187
|
if (total <= 0 || off + total > data.length) total = data.length - off;
|
|
1091
1188
|
|
|
1092
1189
|
records.push({
|
|
1190
|
+
// Unresolvable epoch: keep the raw bits so the deferred retry can
|
|
1191
|
+
// resolve again once keys for that generation are installed.
|
|
1093
1192
|
type: null,
|
|
1094
1193
|
epoch: hdr.epoch,
|
|
1194
|
+
epochBits: hdr.epoch,
|
|
1095
1195
|
seq: null,
|
|
1096
1196
|
content: null,
|
|
1097
1197
|
encrypted: false,
|
|
@@ -1102,7 +1202,7 @@ function parseDtlsDatagram(data, keysByEpoch) {
|
|
|
1102
1202
|
continue;
|
|
1103
1203
|
}
|
|
1104
1204
|
|
|
1105
|
-
let result = decryptEncryptedDtls13(data.subarray(off), keys);
|
|
1205
|
+
let result = decryptEncryptedDtls13(data.subarray(off), keys, fullEpoch);
|
|
1106
1206
|
if (result) {
|
|
1107
1207
|
records.push({
|
|
1108
1208
|
type: result.type,
|
|
@@ -1167,13 +1267,25 @@ function parseDtlsDatagram(data, keysByEpoch) {
|
|
|
1167
1267
|
|
|
1168
1268
|
/** Build ACK payload. acks: [{ epoch, seq }, ...] */
|
|
1169
1269
|
function buildDtlsAck(acks) {
|
|
1170
|
-
|
|
1270
|
+
// RFC 9147 §7:
|
|
1271
|
+
// struct { uint64 epoch; uint64 sequence_number; } RecordNumber;
|
|
1272
|
+
// struct { RecordNumber record_numbers<0..2^16-1>; } ACK;
|
|
1273
|
+
// SIXTEEN bytes per entry. This wrote eight — a uint16 epoch and a uint48
|
|
1274
|
+
// sequence number, which is the DTLS *1.2* record-number layout carried over
|
|
1275
|
+
// by mistake. A conforming peer reads a record_numbers vector whose length is
|
|
1276
|
+
// not a multiple of 16 and answers decode_error.
|
|
1277
|
+
//
|
|
1278
|
+
// It stayed hidden because the only ACKs this stack sent were during the
|
|
1279
|
+
// handshake, where they are usually redundant, and because parseDtlsAck made
|
|
1280
|
+
// the identical mistake — so two copies of this library agreed with each
|
|
1281
|
+
// other and the peer's own well-formed ACKs were quietly misread.
|
|
1282
|
+
let bodyLen = acks.length * 16;
|
|
1171
1283
|
let out = new Uint8Array(2 + bodyLen);
|
|
1172
1284
|
let off = 0;
|
|
1173
1285
|
off = w_u16(out, off, bodyLen);
|
|
1174
1286
|
for (let i = 0; i < acks.length; i++) {
|
|
1175
|
-
off =
|
|
1176
|
-
off =
|
|
1287
|
+
off = w_u64(out, off, acks[i].epoch);
|
|
1288
|
+
off = w_u64(out, off, acks[i].seq);
|
|
1177
1289
|
}
|
|
1178
1290
|
return out;
|
|
1179
1291
|
}
|
|
@@ -1184,9 +1296,10 @@ function parseDtlsAck(data) {
|
|
|
1184
1296
|
let off = 2;
|
|
1185
1297
|
let end = off + bodyLen;
|
|
1186
1298
|
let acks = [];
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
let
|
|
1299
|
+
// 16 bytes per RecordNumber — see buildDtlsAck.
|
|
1300
|
+
while (off + 16 <= end && off + 16 <= data.length) {
|
|
1301
|
+
let epoch = readU64(data, off); off += 8;
|
|
1302
|
+
let seq = readU64(data, off); off += 8;
|
|
1190
1303
|
acks.push({ epoch, seq });
|
|
1191
1304
|
}
|
|
1192
1305
|
return acks;
|
|
@@ -1199,6 +1312,9 @@ export {
|
|
|
1199
1312
|
// AEAD
|
|
1200
1313
|
getAeadAlgo,
|
|
1201
1314
|
|
|
1315
|
+
// DTLS 1.3 epoch resolution
|
|
1316
|
+
resolveReadEpoch,
|
|
1317
|
+
|
|
1202
1318
|
// TLS 1.3
|
|
1203
1319
|
deriveKeys,
|
|
1204
1320
|
getNonce,
|
|
@@ -1253,4 +1369,4 @@ export {
|
|
|
1253
1369
|
// DTLS 1.3 ACK
|
|
1254
1370
|
buildDtlsAck,
|
|
1255
1371
|
parseDtlsAck,
|
|
1256
|
-
};
|
|
1372
|
+
};
|
package/src/session/message.js
CHANGED
|
@@ -208,7 +208,11 @@ function parse_tls_message(data, negotiatedVersion) {
|
|
|
208
208
|
// TLS 1.2 and 1.3 have different wire formats for this message.
|
|
209
209
|
// TLS 1.3: ticket_lifetime | ticket_age_add | ticket_nonce | ticket | extensions
|
|
210
210
|
// TLS 1.2: ticket_lifetime_hint | ticket (RFC 5077)
|
|
211
|
-
|
|
211
|
+
// 0xFEFD is DTLS 1.2, which uses the RFC 5077 wire format exactly as TLS
|
|
212
|
+
// 1.2 does. Testing only for 0x0303 sent a DTLS 1.2 ticket through the
|
|
213
|
+
// TLS 1.3 parser — the same class of bug as the Certificate encoding one:
|
|
214
|
+
// a version comparison that does not know DTLS exists.
|
|
215
|
+
if (negotiatedVersion === 0x0303 || negotiatedVersion === 0xFEFD) {
|
|
212
216
|
out = wire.parse_new_session_ticket_tls12(message.body);
|
|
213
217
|
} else {
|
|
214
218
|
out = wire.parse_new_session_ticket(message.body);
|
package/src/session/ticket.js
CHANGED
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
*/
|
|
17
17
|
|
|
18
18
|
import * as crypto from 'node:crypto';
|
|
19
|
-
import {
|
|
19
|
+
import { timingSafeEqualU8 } from '../utils.js';
|
|
20
20
|
|
|
21
21
|
/* =========================================================================
|
|
22
22
|
* SERVER-SIDE ENCRYPTED BLOB
|
|
@@ -82,8 +82,11 @@ function decrypt_session_blob(blob, ticketKeys) {
|
|
|
82
82
|
|
|
83
83
|
let { key_name, aes_key } = split_ticket_keys(ticketKeys);
|
|
84
84
|
|
|
85
|
-
// Verify key_name matches (allows rotation — caller may try multiple keys)
|
|
86
|
-
|
|
85
|
+
// Verify key_name matches (allows rotation — caller may try multiple keys).
|
|
86
|
+
// Constant-time: utils.js names "ticket key_name" explicitly in the list
|
|
87
|
+
// of comparisons that must not short-circuit, but this call site was
|
|
88
|
+
// still using uint8Equal, which returns on the first differing byte.
|
|
89
|
+
if (!timingSafeEqualU8(blob_key_name, key_name)) return null;
|
|
87
90
|
|
|
88
91
|
let decipher = crypto.createDecipheriv('aes-256-gcm', aes_key, iv);
|
|
89
92
|
decipher.setAuthTag(tag);
|
package/src/tls_session.js
CHANGED
|
@@ -22,6 +22,7 @@ import {
|
|
|
22
22
|
derive_handshake_traffic_secrets_psk_with_hash,
|
|
23
23
|
hkdf_expand_label,
|
|
24
24
|
getHashFn,
|
|
25
|
+
getHashLen,
|
|
25
26
|
derive_exporter_master_secret_with_hash,
|
|
26
27
|
tls13_exporter,
|
|
27
28
|
tls12_exporter,
|
|
@@ -68,6 +69,96 @@ function hexPreview(buf, max) {
|
|
|
68
69
|
}
|
|
69
70
|
|
|
70
71
|
|
|
72
|
+
const MAX_CHAIN_DEPTH = 10;
|
|
73
|
+
|
|
74
|
+
function _x509(x) {
|
|
75
|
+
try { return new crypto.X509Certificate(Buffer.isBuffer(x) ? x : Buffer.from(x)); }
|
|
76
|
+
catch (e) { return null; }
|
|
77
|
+
}
|
|
78
|
+
function _sameCert(a, b) { return a.raw.length === b.raw.length && a.raw.equals(b.raw); }
|
|
79
|
+
function _validNow(x509, now) {
|
|
80
|
+
return !(now < new Date(x509.validFrom) || now > new Date(x509.validTo));
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Build and verify a certificate chain from `chainDer` (leaf first) up to one
|
|
85
|
+
* of `anchors`.
|
|
86
|
+
*
|
|
87
|
+
* This replaces a check that compared ONLY the leaf against each configured CA,
|
|
88
|
+
* which was wrong in both directions:
|
|
89
|
+
*
|
|
90
|
+
* - A real chain (leaf <- intermediate <- root) FAILED, because nothing ever
|
|
91
|
+
* walked the intermediates the peer sent.
|
|
92
|
+
* - The whole check sat behind `if (context.ca)`, so when no CA was
|
|
93
|
+
* configured a peer presenting ANY self-signed certificate was authorized
|
|
94
|
+
* outright — with rejectUnauthorized defaulting to true, which reads as if
|
|
95
|
+
* verification were on.
|
|
96
|
+
*
|
|
97
|
+
* Each link has its signature verified, each non-leaf must actually assert
|
|
98
|
+
* CA:TRUE, and every certificate in the path must be inside its validity
|
|
99
|
+
* window. Intermediates come from the peer and are never trusted on their own;
|
|
100
|
+
* only reaching an anchor ends the walk.
|
|
101
|
+
*
|
|
102
|
+
* Returns { ok: true, depth } or { ok: false, error }.
|
|
103
|
+
*/
|
|
104
|
+
function verify_cert_chain(chainDer, anchors, now) {
|
|
105
|
+
if (!chainDer || chainDer.length === 0) return { ok: false, error: 'NO_PEER_CERTIFICATE' };
|
|
106
|
+
now = now || new Date();
|
|
107
|
+
|
|
108
|
+
const leaf = _x509(chainDer[0].cert || chainDer[0]);
|
|
109
|
+
if (!leaf) return { ok: false, error: 'CERTIFICATE_PARSE_ERROR' };
|
|
110
|
+
|
|
111
|
+
const pool = [];
|
|
112
|
+
for (let i = 1; i < chainDer.length; i++) {
|
|
113
|
+
const x = _x509(chainDer[i].cert || chainDer[i]);
|
|
114
|
+
if (x) pool.push(x);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const trusted = [];
|
|
118
|
+
for (let i = 0; i < (anchors || []).length; i++) {
|
|
119
|
+
const x = _x509(anchors[i]);
|
|
120
|
+
if (x) trusted.push(x);
|
|
121
|
+
}
|
|
122
|
+
if (trusted.length === 0) return { ok: false, error: 'NO_TRUST_ANCHORS' };
|
|
123
|
+
|
|
124
|
+
let current = leaf;
|
|
125
|
+
const used = new Set();
|
|
126
|
+
|
|
127
|
+
for (let depth = 0; depth < MAX_CHAIN_DEPTH; depth++) {
|
|
128
|
+
// Explicitly trusted certificate (pinned leaf, or a self-signed cert the
|
|
129
|
+
// caller deliberately placed in the trust store).
|
|
130
|
+
for (const a of trusted) if (_sameCert(current, a)) return { ok: true, depth: depth };
|
|
131
|
+
|
|
132
|
+
for (const a of trusted) {
|
|
133
|
+
try {
|
|
134
|
+
if (current.checkIssued(a) && current.verify(a.publicKey)) {
|
|
135
|
+
if (!_validNow(a, now)) return { ok: false, error: 'CERT_CHAIN_ANCHOR_EXPIRED' };
|
|
136
|
+
return { ok: true, depth: depth + 1 };
|
|
137
|
+
}
|
|
138
|
+
} catch (e) { /* try next anchor */ }
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
let next = null;
|
|
142
|
+
for (let i = 0; i < pool.length; i++) {
|
|
143
|
+
if (used.has(i)) continue; // cycle guard
|
|
144
|
+
const cand = pool[i];
|
|
145
|
+
try {
|
|
146
|
+
if (!current.checkIssued(cand)) continue;
|
|
147
|
+
if (!current.verify(cand.publicKey)) continue;
|
|
148
|
+
if (cand.ca !== true) return { ok: false, error: 'INVALID_CA_BASIC_CONSTRAINT' };
|
|
149
|
+
if (!_validNow(cand, now)) return { ok: false, error: 'CERT_CHAIN_INTERMEDIATE_EXPIRED' };
|
|
150
|
+
used.add(i); next = cand; break;
|
|
151
|
+
} catch (e) { /* try next candidate */ }
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
if (!next) return { ok: false, error: 'UNABLE_TO_GET_ISSUER_CERT' };
|
|
155
|
+
current = next;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
return { ok: false, error: 'CERT_CHAIN_TOO_LONG' };
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
|
|
71
162
|
function TLSSession(options){
|
|
72
163
|
if (!(this instanceof TLSSession)) return new TLSSession(options);
|
|
73
164
|
options = options || {};
|
|
@@ -354,15 +445,13 @@ function TLSSession(options){
|
|
|
354
445
|
* (D)TLS 1.3 and 1.2 are treated as one version each because the record layer
|
|
355
446
|
* differences live in record.js / the transports, not here.
|
|
356
447
|
*/
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
function
|
|
363
|
-
|
|
364
|
-
context.selected_version === wire.DTLS_VERSION.DTLS1_2;
|
|
365
|
-
}
|
|
448
|
+
// Delegate rather than restate: wire.js owns the "which message format does
|
|
449
|
+
// this version constant mean" question, because that is where getting it
|
|
450
|
+
// wrong silently produced a TLS 1.2 Certificate on a DTLS 1.3 connection.
|
|
451
|
+
// Introducing is_tls13_wire there and leaving a second copy of the same test
|
|
452
|
+
// here would have recreated exactly the drift it was added to remove.
|
|
453
|
+
function is13() { return wire.is_tls13_wire(context.selected_version); }
|
|
454
|
+
function is12() { return wire.is_tls12_wire(context.selected_version); }
|
|
366
455
|
|
|
367
456
|
/**
|
|
368
457
|
* Assemble the ClientHello extension list.
|
|
@@ -1869,13 +1958,13 @@ function TLSSession(options){
|
|
|
1869
1958
|
if(context.state==='connected' && (is13())){
|
|
1870
1959
|
let hashName = negotiated_hash();
|
|
1871
1960
|
let hashLen = getHashLen(hashName);
|
|
1872
|
-
let newRemoteSecret = hkdf_expand_label(hashName, context.remote_app_traffic_secret, 'traffic upd', new Uint8Array(0, label_prefix())
|
|
1961
|
+
let newRemoteSecret = hkdf_expand_label(hashName, context.remote_app_traffic_secret, 'traffic upd', new Uint8Array(0), hashLen, label_prefix());
|
|
1873
1962
|
context.remote_app_traffic_secret = newRemoteSecret;
|
|
1874
1963
|
ev.emit('keyUpdate', { direction: 'receive', secret: newRemoteSecret });
|
|
1875
1964
|
|
|
1876
1965
|
// If peer requested us to update too
|
|
1877
1966
|
if(message.request_update === 1){
|
|
1878
|
-
let newLocalSecret = hkdf_expand_label(hashName, context.local_app_traffic_secret, 'traffic upd', new Uint8Array(0, label_prefix())
|
|
1967
|
+
let newLocalSecret = hkdf_expand_label(hashName, context.local_app_traffic_secret, 'traffic upd', new Uint8Array(0), hashLen, label_prefix());
|
|
1879
1968
|
context.local_app_traffic_secret = newLocalSecret;
|
|
1880
1969
|
|
|
1881
1970
|
// Send our KeyUpdate (not requesting back)
|
|
@@ -3607,7 +3696,17 @@ function TLSSession(options){
|
|
|
3607
3696
|
if(clientFinishedDue || serverCertFinishedDue || serverPskFinishedDue){
|
|
3608
3697
|
|
|
3609
3698
|
let finHashName = negotiated_hash();
|
|
3610
|
-
|
|
3699
|
+
// THE LABEL PREFIX BELONGS IN THE *FOURTH* ARGUMENT. It was being
|
|
3700
|
+
// passed to get_transcript_hash(), which takes only a hash name and
|
|
3701
|
+
// silently ignored it — so get_handshake_finished_with_hash received
|
|
3702
|
+
// labelPrefix=undefined and derived finished_key with the TLS 1.3
|
|
3703
|
+
// prefix "tls13 " instead of DTLS 1.3's "dtls13" (RFC 9147 §5.9).
|
|
3704
|
+
// Both peers running this library make the same substitution and
|
|
3705
|
+
// their Finished MACs agree, so it is invisible in a self-test and
|
|
3706
|
+
// shows up only against a conforming peer as
|
|
3707
|
+
// "Finished verify_data mismatch" — after every message parsed and
|
|
3708
|
+
// every signature verified.
|
|
3709
|
+
let finished_data=get_handshake_finished_with_hash(finHashName,context.local_handshake_traffic_secret,get_transcript_hash(finHashName), label_prefix());
|
|
3611
3710
|
context.local_finished_data = finished_data;
|
|
3612
3711
|
|
|
3613
3712
|
let message_data = build_tls_message({
|
|
@@ -3750,7 +3849,57 @@ function TLSSession(options){
|
|
|
3750
3849
|
if(context.remote_finished !== null){
|
|
3751
3850
|
|
|
3752
3851
|
let remoteFinHashName = negotiated_hash();
|
|
3753
|
-
|
|
3852
|
+
// Same fix as the local Finished above: the prefix is the FOURTH
|
|
3853
|
+
// argument, not an argument to get_transcript_hash().
|
|
3854
|
+
params_to_set['expected_remote_finished']=get_handshake_finished_with_hash(remoteFinHashName,context.remote_handshake_traffic_secret,get_transcript_hash(remoteFinHashName), label_prefix());
|
|
3855
|
+
|
|
3856
|
+
// ── DIAGNOSTIC (LEMON_DEBUG=1 or WEBRTC_DEBUG=1) ──────────────
|
|
3857
|
+
// verify_data is a MAC over three inputs, and a mismatch means
|
|
3858
|
+
// ONE of them differs from what the peer computed. Two peers
|
|
3859
|
+
// running this same library make the SAME choice and agree with
|
|
3860
|
+
// each other, so a self-test can never expose it — only a real
|
|
3861
|
+
// peer (BoringSSL) can. Print all three so the difference is
|
|
3862
|
+
// visible instead of inferred:
|
|
3863
|
+
// • which handshake messages went into the transcript, in
|
|
3864
|
+
// order, with their lengths — a missing or duplicated
|
|
3865
|
+
// message is the usual cause;
|
|
3866
|
+
// • the transcript hash and the label prefix used;
|
|
3867
|
+
// • both verify_data values.
|
|
3868
|
+
try {
|
|
3869
|
+
if (typeof process !== 'undefined' && process.env &&
|
|
3870
|
+
(process.env.LEMON_DEBUG === '1' || process.env.WEBRTC_DEBUG === '1')) {
|
|
3871
|
+
var _hsNames = { 1:'ClientHello', 2:'ServerHello', 4:'NewSessionTicket',
|
|
3872
|
+
5:'EndOfEarlyData', 8:'EncryptedExtensions', 11:'Certificate',
|
|
3873
|
+
13:'CertificateRequest', 15:'CertificateVerify',
|
|
3874
|
+
20:'Finished', 24:'KeyUpdate', 254:'MessageHash' };
|
|
3875
|
+
var _t = context.transcript || [];
|
|
3876
|
+
var _list = [];
|
|
3877
|
+
for (var _ti = 0; _ti < _t.length; _ti++) {
|
|
3878
|
+
var _e = _t[_ti];
|
|
3879
|
+
if (!_e || !_e.length) { _list.push('?'); continue; }
|
|
3880
|
+
_list.push((_hsNames[_e[0]] || ('type' + _e[0])) + '(' + _e.length + ')');
|
|
3881
|
+
}
|
|
3882
|
+
var _hex = function (u8) {
|
|
3883
|
+
if (!u8) return 'null';
|
|
3884
|
+
var s = '';
|
|
3885
|
+
for (var _i = 0; _i < u8.length && _i < 16; _i++) {
|
|
3886
|
+
s += ('0' + u8[_i].toString(16)).slice(-2);
|
|
3887
|
+
}
|
|
3888
|
+
return s + (u8.length > 16 ? '..' : '');
|
|
3889
|
+
};
|
|
3890
|
+
console.log('[lemon-fin] role=' + (context.is_server ? 'server' : 'client') +
|
|
3891
|
+
' version=0x' + (context.selected_version || 0).toString(16) +
|
|
3892
|
+
' labelPrefix="' + label_prefix() + '"' +
|
|
3893
|
+
' hash=' + remoteFinHashName);
|
|
3894
|
+
console.log('[lemon-fin] transcript(' + _t.length + '): ' + _list.join(' → '));
|
|
3895
|
+
console.log('[lemon-fin] transcriptHash=' +
|
|
3896
|
+
_hex(get_transcript_hash(remoteFinHashName, label_prefix())));
|
|
3897
|
+
console.log('[lemon-fin] remoteSecret=' + _hex(context.remote_handshake_traffic_secret));
|
|
3898
|
+
console.log('[lemon-fin] peerSent=' + _hex(context.remote_finished));
|
|
3899
|
+
console.log('[lemon-fin] weExpect=' + _hex(params_to_set['expected_remote_finished']));
|
|
3900
|
+
}
|
|
3901
|
+
} catch (_eDiag) {}
|
|
3902
|
+
// ─────────────────────────────────────────────────────────────
|
|
3754
3903
|
|
|
3755
3904
|
}
|
|
3756
3905
|
|
|
@@ -4085,24 +4234,31 @@ function TLSSession(options){
|
|
|
4085
4234
|
}
|
|
4086
4235
|
}
|
|
4087
4236
|
|
|
4088
|
-
// Verify against
|
|
4089
|
-
|
|
4090
|
-
|
|
4091
|
-
|
|
4092
|
-
|
|
4093
|
-
|
|
4094
|
-
|
|
4095
|
-
|
|
4096
|
-
|
|
4097
|
-
|
|
4098
|
-
|
|
4099
|
-
|
|
4100
|
-
|
|
4101
|
-
|
|
4102
|
-
|
|
4103
|
-
|
|
4104
|
-
|
|
4105
|
-
|
|
4237
|
+
// Verify the full chain against the configured trust anchors.
|
|
4238
|
+
//
|
|
4239
|
+
// NOTE the `context.ca == null` branch. Previously the entire check was
|
|
4240
|
+
// wrapped in `if (context.ca)`, so "no CA configured" meant "authorize
|
|
4241
|
+
// everything" — while rejectUnauthorized defaulted to true, which reads
|
|
4242
|
+
// as though verification were on. That is a MITM hole for any plain-TLS
|
|
4243
|
+
// consumer of this library. It is preserved here ONLY because callers
|
|
4244
|
+
// (notably the WebRTC/DTLS path, which authenticates by SDP fingerprint
|
|
4245
|
+
// and sets rejectUnauthorized:false) may rely on it — but it is now
|
|
4246
|
+
// explicit, recorded in authorizationError, and trivial to flip.
|
|
4247
|
+
// See LEMON_TLS_INSECURE_NO_CA below.
|
|
4248
|
+
if (context.ca == null ||
|
|
4249
|
+
(Array.isArray(context.ca) && context.ca.length === 0)) {
|
|
4250
|
+
context.peerAuthorized = true;
|
|
4251
|
+
context.authorizationError = null;
|
|
4252
|
+
context.certChainUnverified = true; // no trust anchors were available
|
|
4253
|
+
return;
|
|
4254
|
+
}
|
|
4255
|
+
|
|
4256
|
+
let cas = Array.isArray(context.ca) ? context.ca : [context.ca];
|
|
4257
|
+
let chainResult = verify_cert_chain(context.remote_cert_chain, cas, now);
|
|
4258
|
+
if (!chainResult.ok) {
|
|
4259
|
+
context.authorizationError = chainResult.error;
|
|
4260
|
+
context.peerAuthorized = false;
|
|
4261
|
+
return;
|
|
4106
4262
|
}
|
|
4107
4263
|
|
|
4108
4264
|
// All checks passed
|
|
@@ -4729,7 +4885,7 @@ function TLSSession(options){
|
|
|
4729
4885
|
let hashLen = getHashLen(hashName);
|
|
4730
4886
|
|
|
4731
4887
|
// Derive new local traffic secret
|
|
4732
|
-
let newLocalSecret = hkdf_expand_label(hashName, context.local_app_traffic_secret, 'traffic upd', new Uint8Array(0, label_prefix())
|
|
4888
|
+
let newLocalSecret = hkdf_expand_label(hashName, context.local_app_traffic_secret, 'traffic upd', new Uint8Array(0), hashLen, label_prefix());
|
|
4733
4889
|
context.local_app_traffic_secret = newLocalSecret;
|
|
4734
4890
|
|
|
4735
4891
|
// Send KeyUpdate message
|
package/src/tls_socket.js
CHANGED
|
@@ -53,7 +53,6 @@ function sigalgCodeToName(code) {
|
|
|
53
53
|
|
|
54
54
|
// ==== עזרי המרה ====
|
|
55
55
|
function toBuf(u8){ return Buffer.isBuffer(u8) ? u8 : Buffer.from(u8 || []); }
|
|
56
|
-
function toU8(buf){ return (buf instanceof Uint8Array) ? buf : new Uint8Array(buf || []); }
|
|
57
56
|
|
|
58
57
|
function parseVersion(v) {
|
|
59
58
|
if (typeof v === 'number') return v;
|
package/src/utils.js
CHANGED
|
@@ -54,7 +54,13 @@ function arraysEqual(a, b) {
|
|
|
54
54
|
return false;
|
|
55
55
|
}
|
|
56
56
|
}else{
|
|
57
|
-
|
|
57
|
+
// Either element may be null/undefined here: the branch above requires
|
|
58
|
+
// BOTH to be non-null typed arrays, so a null slips through to this
|
|
59
|
+
// side and the `.constructor` reads below threw a TypeError. Settle
|
|
60
|
+
// those cases by identity before anything is dereferenced.
|
|
61
|
+
if(a[i]==null || b[i]==null){
|
|
62
|
+
if(a[i]!==b[i]) return false;
|
|
63
|
+
}else if(typeof a[i]=='string' && typeof b[i]=='string'){
|
|
58
64
|
if (a[i] !== b[i]){
|
|
59
65
|
return false;
|
|
60
66
|
}
|