lemon-tls 0.7.0 → 0.10.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 -20
- package/index.cjs +9 -7
- package/index.d.ts +323 -23
- package/index.js +126 -0
- package/package.json +39 -6
- package/src/compat.js +325 -46
- package/src/crypto.js +19 -0
- package/src/dtls_session.js +141 -17
- package/src/dtls_socket.js +32 -0
- package/src/node_options.js +138 -0
- package/src/record.js +192 -57
- package/src/secure_context.js +63 -4
- package/src/session/cert_compression.js +157 -0
- package/src/session/ecdh.js +237 -10
- package/src/session/ech.js +715 -0
- package/src/session/hpke.js +414 -0
- package/src/session/message.js +39 -4
- package/src/session/signing.js +56 -3
- package/src/session/ticket.js +38 -25
- package/src/tls_session.js +2835 -205
- package/src/tls_socket.js +839 -148
- package/src/utils.js +68 -1
- package/src/wire.js +148 -9
- package/types/crypto.d.ts +40 -0
- package/types/record.d.ts +40 -0
- package/types/session.d.ts +3 -0
- package/types/wire.d.ts +62 -0
package/src/record.js
CHANGED
|
@@ -373,10 +373,52 @@ function buildAad12(seqNum, recordType, plaintextLen) {
|
|
|
373
373
|
}
|
|
374
374
|
|
|
375
375
|
/**
|
|
376
|
-
*
|
|
376
|
+
* TLS 1.2 ChaCha20-Poly1305 (RFC 7905), shared by the three TLS 1.2 record
|
|
377
|
+
* functions below. Told apart from AES-GCM the way the DTLS 1.2 functions do
|
|
378
|
+
* it: by the write IV the key block gave the suite — 12 bytes for ChaCha20,
|
|
379
|
+
* a 4-byte salt for GCM. No explicit nonce on the wire; the nonce is the IV
|
|
380
|
+
* XOR the sequence number; the AAD is the same as GCM's.
|
|
381
|
+
*
|
|
382
|
+
* The TLS 1.2 functions handled GCM only. A ChaCha20 suite was negotiated —
|
|
383
|
+
* it is in the default offer, and clients without AES hardware prefer it —
|
|
384
|
+
* and then every record failed: the peer's Finished did not decrypt
|
|
385
|
+
* (bad_record_mac), and ours could not have been read either.
|
|
386
|
+
*/
|
|
387
|
+
function chacha12Seal(pt, key, iv12, seqNum, recordType) {
|
|
388
|
+
const cipher = crypto.createCipheriv('chacha20-poly1305', key, getChachaNonce12(iv12, seqNum), { authTagLength: 16 });
|
|
389
|
+
cipher.setAAD(buildAad12(seqNum, recordType, pt.length), { plaintextLength: pt.length });
|
|
390
|
+
const ct = cipher.update(pt);
|
|
391
|
+
cipher.final();
|
|
392
|
+
return { ct: ct, tag: cipher.getAuthTag() };
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
function chacha12Open(fragment, key, iv12, seqNum, recordType) {
|
|
396
|
+
if (fragment.length < 16) throw new Error('TLS 1.2 ChaCha20 fragment too short');
|
|
397
|
+
const ct = fragment.subarray(0, fragment.length - 16);
|
|
398
|
+
const tag = fragment.subarray(fragment.length - 16);
|
|
399
|
+
const decipher = crypto.createDecipheriv('chacha20-poly1305', key, getChachaNonce12(iv12, seqNum), { authTagLength: 16 });
|
|
400
|
+
decipher.setAAD(buildAad12(seqNum, recordType, ct.length), { plaintextLength: ct.length });
|
|
401
|
+
decipher.setAuthTag(tag);
|
|
402
|
+
const pt = decipher.update(ct);
|
|
403
|
+
decipher.final();
|
|
404
|
+
return pt;
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
/**
|
|
408
|
+
* Encrypt a TLS 1.2 AEAD record fragment.
|
|
409
|
+
* AES-GCM (4-byte salt): explicit_nonce(8) || ciphertext || tag(16).
|
|
410
|
+
* ChaCha20-Poly1305 (12-byte IV): ciphertext || tag(16).
|
|
377
411
|
* Perf: computes seq-bytes once for both explicit nonce and AAD (was computed twice).
|
|
378
412
|
*/
|
|
379
413
|
function encrypt12(pt, key, salt4, seqNum, recordType) {
|
|
414
|
+
if (salt4.length === 12) {
|
|
415
|
+
const sealed = chacha12Seal(pt, key, salt4, seqNum, recordType);
|
|
416
|
+
const out12 = new Uint8Array(sealed.ct.length + 16);
|
|
417
|
+
out12.set(sealed.ct, 0);
|
|
418
|
+
out12.set(sealed.tag, sealed.ct.length);
|
|
419
|
+
return out12;
|
|
420
|
+
}
|
|
421
|
+
|
|
380
422
|
// Shared big-endian seq encoding (used as both explicit_nonce and AAD[0..8])
|
|
381
423
|
const hi = (seqNum / 0x100000000) | 0;
|
|
382
424
|
const lo = seqNum >>> 0;
|
|
@@ -437,6 +479,23 @@ function encrypt12(pt, key, salt4, seqNum, recordType) {
|
|
|
437
479
|
*/
|
|
438
480
|
function encryptCompleteRecord12(pt, key, salt4, seqNum, recordType, version) {
|
|
439
481
|
const ptLen = pt.length;
|
|
482
|
+
|
|
483
|
+
// ChaCha20-Poly1305: header || ciphertext || tag — no explicit IV.
|
|
484
|
+
if (salt4.length === 12) {
|
|
485
|
+
const sealed = chacha12Seal(pt, key, salt4, seqNum, recordType);
|
|
486
|
+
const cBodyLen = ptLen + 16;
|
|
487
|
+
const cVer = version || 0x0303;
|
|
488
|
+
const cRec = Buffer.allocUnsafe(5 + cBodyLen);
|
|
489
|
+
cRec[0] = recordType & 0xff;
|
|
490
|
+
cRec[1] = (cVer >>> 8) & 0xff;
|
|
491
|
+
cRec[2] = cVer & 0xff;
|
|
492
|
+
cRec[3] = (cBodyLen >>> 8) & 0xff;
|
|
493
|
+
cRec[4] = cBodyLen & 0xff;
|
|
494
|
+
sealed.ct.copy(cRec, 5);
|
|
495
|
+
sealed.tag.copy(cRec, 5 + ptLen);
|
|
496
|
+
return cRec;
|
|
497
|
+
}
|
|
498
|
+
|
|
440
499
|
// Record body: 8 explicit_iv + ptLen ciphertext + 16 tag
|
|
441
500
|
const bodyLen = 8 + ptLen + 16;
|
|
442
501
|
const ver = version || 0x0303;
|
|
@@ -493,11 +552,15 @@ function encryptCompleteRecord12(pt, key, salt4, seqNum, recordType, version) {
|
|
|
493
552
|
}
|
|
494
553
|
|
|
495
554
|
/**
|
|
496
|
-
* Decrypt a TLS 1.2
|
|
555
|
+
* Decrypt a TLS 1.2 AEAD record fragment.
|
|
556
|
+
* AES-GCM input: explicit_nonce(8) || ciphertext || tag(16).
|
|
557
|
+
* ChaCha20-Poly1305 input: ciphertext || tag(16).
|
|
497
558
|
* Perf: subarray (zero-copy views) instead of slice (copies); returns Node Buffer
|
|
498
559
|
* (which IS a Uint8Array subclass) instead of re-copying to a plain Uint8Array.
|
|
499
560
|
*/
|
|
500
561
|
function decrypt12(fragment, key, salt4, seqNum, recordType) {
|
|
562
|
+
if (salt4.length === 12) return chacha12Open(fragment, key, salt4, seqNum, recordType);
|
|
563
|
+
|
|
501
564
|
if (fragment.length < 24) throw new Error('TLS 1.2 fragment too short');
|
|
502
565
|
|
|
503
566
|
// Zero-copy views over the input
|
|
@@ -602,6 +665,21 @@ function readU48(buf, off) {
|
|
|
602
665
|
}
|
|
603
666
|
|
|
604
667
|
/** AES-ECB encrypt a single 16-byte block (for DTLS 1.3 record number encryption). */
|
|
668
|
+
// One AES-ECB cipher per sn_key, reused for every record's number mask
|
|
669
|
+
// (RFC 9147 §4.2.3) instead of creating one per record. ECB keeps no state
|
|
670
|
+
// between blocks, so update() on 16 bytes always returns exactly that block's
|
|
671
|
+
// encryption. Keyed by the key object, which lives as long as its epoch's keys.
|
|
672
|
+
var _snEcbCache = new WeakMap();
|
|
673
|
+
function _snKeyEcb(key) {
|
|
674
|
+
var c = _snEcbCache.get(key);
|
|
675
|
+
if (!c) {
|
|
676
|
+
c = crypto.createCipheriv(key.length === 32 ? 'aes-256-ecb' : 'aes-128-ecb', key, null);
|
|
677
|
+
c.setAutoPadding(false);
|
|
678
|
+
_snEcbCache.set(key, c);
|
|
679
|
+
}
|
|
680
|
+
return c;
|
|
681
|
+
}
|
|
682
|
+
|
|
605
683
|
function aesEcbEncrypt(key, block) {
|
|
606
684
|
let algo = key.length === 32 ? 'aes-256-ecb' : 'aes-128-ecb';
|
|
607
685
|
let cipher = crypto.createCipheriv(algo, key, null);
|
|
@@ -722,8 +800,8 @@ function decryptDtls12(fragment, key, ivSalt, epoch, seq, type) {
|
|
|
722
800
|
// ChaCha20-Poly1305: no explicit nonce; nonce = iv(12) XOR (epoch||seq).
|
|
723
801
|
if (ivSalt.length === 12) {
|
|
724
802
|
if (fragment.length < 16) throw new Error('DTLS 1.2 ChaCha fragment too short');
|
|
725
|
-
let ct = fragment.
|
|
726
|
-
let tag = fragment.
|
|
803
|
+
let ct = fragment.subarray(0, fragment.length - 16); // views: only read
|
|
804
|
+
let tag = fragment.subarray(fragment.length - 16);
|
|
727
805
|
let seq64 = epoch * 0x1000000000000 + seq;
|
|
728
806
|
let nonce = getChachaNonce12(ivSalt, seq64);
|
|
729
807
|
let aad = buildDtlsAad12(epoch, seq, type, ct.length);
|
|
@@ -732,14 +810,16 @@ function decryptDtls12(fragment, key, ivSalt, epoch, seq, type) {
|
|
|
732
810
|
decipher.setAuthTag(tag);
|
|
733
811
|
let pt = decipher.update(ct);
|
|
734
812
|
decipher.final();
|
|
735
|
-
|
|
813
|
+
// A view, not a copy: pt is a fresh Buffer that owns its whole
|
|
814
|
+
// ArrayBuffer and nothing else holds it. Same Uint8Array type as before.
|
|
815
|
+
return new Uint8Array(pt.buffer, pt.byteOffset, pt.byteLength);
|
|
736
816
|
}
|
|
737
817
|
|
|
738
818
|
// AES-GCM: strip the 8-byte explicit nonce.
|
|
739
819
|
if (fragment.length < 24) throw new Error('DTLS 1.2 fragment too short');
|
|
740
|
-
let explicit = fragment.
|
|
741
|
-
let tag = fragment.
|
|
742
|
-
let ct = fragment.
|
|
820
|
+
let explicit = fragment.subarray(0, 8); // views: only read
|
|
821
|
+
let tag = fragment.subarray(fragment.length - 16);
|
|
822
|
+
let ct = fragment.subarray(8, fragment.length - 16);
|
|
743
823
|
let nonce = getNonce12(ivSalt, explicit);
|
|
744
824
|
let aad = buildDtlsAad12(epoch, seq, type, ct.length);
|
|
745
825
|
let algo = key.length === 16 ? 'aes-128-gcm' : 'aes-256-gcm';
|
|
@@ -748,13 +828,44 @@ function decryptDtls12(fragment, key, ivSalt, epoch, seq, type) {
|
|
|
748
828
|
decipher.setAuthTag(tag);
|
|
749
829
|
let pt = decipher.update(ct);
|
|
750
830
|
decipher.final();
|
|
751
|
-
|
|
831
|
+
// A view, not a copy — see the ChaCha branch above.
|
|
832
|
+
return new Uint8Array(pt.buffer, pt.byteOffset, pt.byteLength);
|
|
752
833
|
}
|
|
753
834
|
|
|
754
835
|
/** Build complete encrypted DTLS 1.2 record (header + encrypted payload). */
|
|
755
836
|
function buildEncryptedDtls12(type, epoch, seq, plaintext, keys) {
|
|
756
|
-
|
|
757
|
-
|
|
837
|
+
// Same bytes as buildDtlsPlaintext(type, epoch, seq, encryptDtls12(...)),
|
|
838
|
+
// written straight into the record instead of through two more buffers.
|
|
839
|
+
let key = keys.key, ivSalt = keys.iv;
|
|
840
|
+
let chacha = ivSalt.length === 12;
|
|
841
|
+
let explicitLen = chacha ? 0 : 8;
|
|
842
|
+
let fragLen = explicitLen + plaintext.length + 16;
|
|
843
|
+
let out = new Uint8Array(13 + fragLen);
|
|
844
|
+
let off = 0;
|
|
845
|
+
off = w_u8(out, off, type);
|
|
846
|
+
off = w_u16(out, off, 0xFEFD);
|
|
847
|
+
off = w_u16(out, off, epoch);
|
|
848
|
+
off = w_u48(out, off, seq);
|
|
849
|
+
off = w_u16(out, off, fragLen);
|
|
850
|
+
let aad = buildDtlsAad12(epoch, seq, type, plaintext.length);
|
|
851
|
+
let cipher;
|
|
852
|
+
if (chacha) {
|
|
853
|
+
let nonce = getChachaNonce12(ivSalt, epoch * 0x1000000000000 + seq);
|
|
854
|
+
cipher = crypto.createCipheriv('chacha20-poly1305', key, nonce, { authTagLength: 16 });
|
|
855
|
+
cipher.setAAD(aad, { plaintextLength: plaintext.length });
|
|
856
|
+
} else {
|
|
857
|
+
// AES-GCM: explicit nonce (= record seq) prepended.
|
|
858
|
+
let explicit = seqToBytes(seq);
|
|
859
|
+
out.set(explicit, off); off += 8;
|
|
860
|
+
cipher = crypto.createCipheriv(key.length === 16 ? 'aes-128-gcm' : 'aes-256-gcm', key, getNonce12(ivSalt, explicit));
|
|
861
|
+
cipher.setAAD(aad);
|
|
862
|
+
}
|
|
863
|
+
let ct = cipher.update(plaintext);
|
|
864
|
+
out.set(ct, off); off += ct.length;
|
|
865
|
+
let fin = cipher.final();
|
|
866
|
+
if (fin.length) { out.set(fin, off); off += fin.length; }
|
|
867
|
+
out.set(cipher.getAuthTag(), off);
|
|
868
|
+
return out;
|
|
758
869
|
}
|
|
759
870
|
|
|
760
871
|
|
|
@@ -832,7 +943,7 @@ function maskRecordNumber(snKey, rnBytes, ciphertext, algo) {
|
|
|
832
943
|
let c = crypto.createCipheriv('chacha20', snKey, iv);
|
|
833
944
|
mask = new Uint8Array(c.update(Buffer.alloc(16)));
|
|
834
945
|
} else {
|
|
835
|
-
mask =
|
|
946
|
+
mask = _snKeyEcb(snKey).update(ciphertext.subarray(0, 16));
|
|
836
947
|
}
|
|
837
948
|
|
|
838
949
|
let out = new Uint8Array(rnBytes.length);
|
|
@@ -860,42 +971,43 @@ function buildEncryptedDtls13(innerType, plaintext, seq, epoch, keys) {
|
|
|
860
971
|
let info = buildUnifiedHdr(epoch, true, true, false);
|
|
861
972
|
|
|
862
973
|
// Plaintext record number
|
|
863
|
-
let
|
|
864
|
-
rn[0] = (seq >>> 8) & 0xFF;
|
|
865
|
-
rn[1] = seq & 0xFF;
|
|
974
|
+
let rn0 = (seq >>> 8) & 0xFF, rn1 = seq & 0xFF;
|
|
866
975
|
|
|
867
|
-
// Inner plaintext
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
let encLen = inner.length + 16;
|
|
976
|
+
// Inner plaintext = content || content_type. Fed to the cipher in two
|
|
977
|
+
// pieces rather than first copied into one buffer.
|
|
978
|
+
let innerLen = plaintext.length + 1;
|
|
979
|
+
let encLen = innerLen + 16;
|
|
873
980
|
|
|
874
981
|
// AAD = header with PLAINTEXT record number (before RN encryption)
|
|
875
982
|
let aad = new Uint8Array(5);
|
|
876
983
|
aad[0] = info;
|
|
877
|
-
aad[1] =
|
|
878
|
-
aad[2] =
|
|
984
|
+
aad[1] = rn0;
|
|
985
|
+
aad[2] = rn1;
|
|
879
986
|
aad[3] = (encLen >>> 8) & 0xFF;
|
|
880
987
|
aad[4] = encLen & 0xFF;
|
|
881
988
|
|
|
882
989
|
// Nonce (reuse TLS 1.3 nonce construction)
|
|
883
990
|
let nonce = getNonce(keys.iv, seq);
|
|
884
991
|
|
|
885
|
-
// AEAD encrypt
|
|
992
|
+
// AEAD encrypt, written straight into the record: info(1) + encrypted
|
|
993
|
+
// rn(2) + length(2) + ciphertext. (It used to pass through three buffers:
|
|
994
|
+
// inner plaintext, ciphertext+tag, then the record.)
|
|
886
995
|
let cipher = crypto.createCipheriv(algo, keys.key, nonce,
|
|
887
996
|
isChaCha ? { authTagLength: 16 } : undefined);
|
|
888
|
-
cipher.setAAD(aad, isChaCha ? { plaintextLength:
|
|
889
|
-
let
|
|
890
|
-
|
|
891
|
-
let
|
|
892
|
-
|
|
893
|
-
let
|
|
894
|
-
|
|
895
|
-
|
|
997
|
+
cipher.setAAD(aad, isChaCha ? { plaintextLength: innerLen } : undefined);
|
|
998
|
+
let record = new Uint8Array(5 + encLen);
|
|
999
|
+
let off = 5;
|
|
1000
|
+
let ct1 = cipher.update(plaintext);
|
|
1001
|
+
record.set(ct1, off); off += ct1.length;
|
|
1002
|
+
let ct2 = cipher.update(_contentTypeByte(innerType));
|
|
1003
|
+
record.set(ct2, off); off += ct2.length;
|
|
1004
|
+
let ct3 = cipher.final();
|
|
1005
|
+
if (ct3.length) { record.set(ct3, off); off += ct3.length; }
|
|
1006
|
+
record.set(cipher.getAuthTag(), off);
|
|
1007
|
+
let ciphertext = record.subarray(5);
|
|
896
1008
|
|
|
897
1009
|
// Encrypt record number
|
|
898
|
-
let encRn = maskRecordNumber(keys.snKey,
|
|
1010
|
+
let encRn = maskRecordNumber(keys.snKey, [rn0, rn1], ciphertext, algo);
|
|
899
1011
|
|
|
900
1012
|
// ── DIAGNOSTIC (LEMON_DEBUG=1 / WEBRTC_DEBUG=1) ──────────────────────
|
|
901
1013
|
// Everything a peer needs to PARSE this record, printed as we build it.
|
|
@@ -905,34 +1017,39 @@ function buildEncryptedDtls13(innerType, plaintext, seq, epoch, keys) {
|
|
|
905
1017
|
// present), the sequence number before and after masking, and the
|
|
906
1018
|
// declared length against the actual ciphertext size. If two records
|
|
907
1019
|
// are accepted and the third is not, the difference between them shows
|
|
908
|
-
// up here.
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
(process.env.LEMON_DEBUG === '1' || process.env.WEBRTC_DEBUG === '1')) {
|
|
1020
|
+
// up here. (Flag read once at load: process.env is slow to read per record.)
|
|
1021
|
+
if (_REC_DEBUG) {
|
|
1022
|
+
try {
|
|
912
1023
|
var _h = function (b) { return ('0' + (b & 0xFF).toString(16)).slice(-2); };
|
|
913
1024
|
console.log('[lemon-rec] TX info=0x' + _h(info) +
|
|
914
1025
|
' [C=' + ((info >> 4) & 1) + ' S=' + ((info >> 3) & 1) +
|
|
915
1026
|
' L=' + ((info >> 2) & 1) + ' epoch=' + (info & 0x03) + ']' +
|
|
916
|
-
' seqPlain=' + _h(
|
|
1027
|
+
' seqPlain=' + _h(rn0) + _h(rn1) +
|
|
917
1028
|
' seqMasked=' + _h(encRn[0]) + _h(encRn[1]) +
|
|
918
1029
|
' declaredLen=' + ciphertext.length +
|
|
919
|
-
' aadLen=' +
|
|
1030
|
+
' aadLen=' + aad.length +
|
|
920
1031
|
' snKey=' + (keys.snKey ? 'set' : 'MISSING'));
|
|
921
|
-
}
|
|
922
|
-
}
|
|
1032
|
+
} catch (_e) {}
|
|
1033
|
+
}
|
|
923
1034
|
// ─────────────────────────────────────────────────────────────────────
|
|
924
1035
|
|
|
925
|
-
// Assemble: info(1) + encrypted_rn(2) + length(2) + ciphertext
|
|
926
|
-
let record = new Uint8Array(5 + ciphertext.length);
|
|
927
1036
|
record[0] = info;
|
|
928
1037
|
record[1] = encRn[0];
|
|
929
1038
|
record[2] = encRn[1];
|
|
930
1039
|
record[3] = (ciphertext.length >>> 8) & 0xFF;
|
|
931
1040
|
record[4] = ciphertext.length & 0xFF;
|
|
932
|
-
record.set(ciphertext, 5);
|
|
933
1041
|
return record;
|
|
934
1042
|
}
|
|
935
1043
|
|
|
1044
|
+
var _REC_DEBUG = typeof process !== 'undefined' && process.env &&
|
|
1045
|
+
(process.env.LEMON_DEBUG === '1' || process.env.WEBRTC_DEBUG === '1');
|
|
1046
|
+
|
|
1047
|
+
// The one-byte content type appended to a DTLS 1.3 inner plaintext.
|
|
1048
|
+
var _CT_BYTES = [];
|
|
1049
|
+
function _contentTypeByte(t) {
|
|
1050
|
+
return _CT_BYTES[t] || (_CT_BYTES[t] = new Uint8Array([t]));
|
|
1051
|
+
}
|
|
1052
|
+
|
|
936
1053
|
/**
|
|
937
1054
|
* Reconstruct a full DTLS 1.3 record sequence number from its truncated wire
|
|
938
1055
|
* encoding (RFC 9147 §4.2.2).
|
|
@@ -1027,7 +1144,9 @@ function decryptEncryptedDtls13(data, keys, fullEpoch) {
|
|
|
1027
1144
|
}
|
|
1028
1145
|
|
|
1029
1146
|
if (off + ctLen > data.length) return null;
|
|
1030
|
-
|
|
1147
|
+
// A view: only read here (record-number mask, AEAD input); the plaintext
|
|
1148
|
+
// comes out of the decipher as a fresh buffer.
|
|
1149
|
+
let ciphertext = data.subarray(off, off + ctLen);
|
|
1031
1150
|
|
|
1032
1151
|
// Resolve the AEAD before unmasking: the record-number mask is built with
|
|
1033
1152
|
// the same primitive as the AEAD (AES-ECB vs ChaCha20), so `algo` is needed
|
|
@@ -1102,7 +1221,8 @@ function decryptEncryptedDtls13(data, keys, fullEpoch) {
|
|
|
1102
1221
|
seq = acceptedSeq;
|
|
1103
1222
|
|
|
1104
1223
|
{
|
|
1105
|
-
|
|
1224
|
+
// A view, not a copy: pt is the decipher's fresh Buffer, owned by no one else.
|
|
1225
|
+
let inner = parseInnerPlaintext(new Uint8Array(pt.buffer, pt.byteOffset, pt.byteLength));
|
|
1106
1226
|
|
|
1107
1227
|
// RFC 9147 §4.5.1 anti-replay: only AFTER the tag verifies do we consult
|
|
1108
1228
|
// and update the sliding window. Checking before deprotection would let a
|
|
@@ -1111,23 +1231,34 @@ function decryptEncryptedDtls13(data, keys, fullEpoch) {
|
|
|
1111
1231
|
// next to maxReadSeq, because both are per-epoch receive state.
|
|
1112
1232
|
// replayWindow: bitmask of the 64 seqs at or below maxReadSeq that have
|
|
1113
1233
|
// been accepted. Bit 0 = maxReadSeq, bit k = maxReadSeq - k.
|
|
1234
|
+
// The 64-bit window is kept as two 32-bit numbers (replayLo = bits
|
|
1235
|
+
// 0..31, replayHi = 32..63): same window, no BigInt allocations per
|
|
1236
|
+
// record.
|
|
1114
1237
|
if (keys.maxReadSeq === undefined || keys.maxReadSeq < 0) {
|
|
1115
1238
|
// First accepted record in this epoch: seed the window with this seq as
|
|
1116
1239
|
// the high-water mark (bit 0 set), so an immediate replay of it is
|
|
1117
1240
|
// caught below rather than sailing through an unset window.
|
|
1118
1241
|
keys.maxReadSeq = seq;
|
|
1119
|
-
keys.
|
|
1242
|
+
keys.replayLo = 1; keys.replayHi = 0;
|
|
1120
1243
|
} else if (seq > keys.maxReadSeq) {
|
|
1121
1244
|
// Advance: shift the window up by the gap, then set bit 0.
|
|
1122
|
-
let shift =
|
|
1123
|
-
|
|
1245
|
+
let shift = seq - keys.maxReadSeq;
|
|
1246
|
+
let lo = keys.replayLo, hi = keys.replayHi;
|
|
1247
|
+
if (shift >= 64) { lo = 0; hi = 0; }
|
|
1248
|
+
else if (shift >= 32) { hi = (lo << (shift - 32)) >>> 0; lo = 0; }
|
|
1249
|
+
else { hi = ((hi << shift) | (lo >>> (32 - shift))) >>> 0; lo = (lo << shift) >>> 0; }
|
|
1250
|
+
keys.replayLo = (lo | 1) >>> 0; keys.replayHi = hi;
|
|
1124
1251
|
keys.maxReadSeq = seq;
|
|
1125
1252
|
} else {
|
|
1126
|
-
let back =
|
|
1127
|
-
if (back >=
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
|
|
1253
|
+
let back = keys.maxReadSeq - seq;
|
|
1254
|
+
if (back >= 64) return null; // too old — outside window
|
|
1255
|
+
if (back < 32) {
|
|
1256
|
+
if ((keys.replayLo >>> back) & 1) return null; // already seen — replay
|
|
1257
|
+
keys.replayLo = (keys.replayLo | (1 << back)) >>> 0;
|
|
1258
|
+
} else {
|
|
1259
|
+
if ((keys.replayHi >>> (back - 32)) & 1) return null;
|
|
1260
|
+
keys.replayHi = (keys.replayHi | (1 << (back - 32))) >>> 0;
|
|
1261
|
+
}
|
|
1131
1262
|
}
|
|
1132
1263
|
|
|
1133
1264
|
return {
|
|
@@ -1225,19 +1356,23 @@ function parseDtlsDatagram(data, keysByEpoch) {
|
|
|
1225
1356
|
let length = readU16(data, off + 11);
|
|
1226
1357
|
if (off + 13 + length > data.length) break;
|
|
1227
1358
|
|
|
1228
|
-
let payload =
|
|
1359
|
+
let payload = null;
|
|
1229
1360
|
let encrypted = false;
|
|
1230
1361
|
|
|
1231
|
-
// DTLS 1.2: decrypt if epoch > 0 and keys available
|
|
1362
|
+
// DTLS 1.2: decrypt if epoch > 0 and keys available. The ciphertext is
|
|
1363
|
+
// only read (the plaintext comes out of the decipher as a fresh
|
|
1364
|
+
// buffer), so it is passed as a view; a record kept as it is — not
|
|
1365
|
+
// decrypted — gets its own copy, as before.
|
|
1232
1366
|
if (epoch > 0 && keysByEpoch && keysByEpoch[epoch]) {
|
|
1233
1367
|
let keys = keysByEpoch[epoch];
|
|
1234
1368
|
try {
|
|
1235
|
-
payload = decryptDtls12(
|
|
1369
|
+
payload = decryptDtls12(data.subarray(off + 13, off + 13 + length), keys.key, keys.iv, epoch, seq, type);
|
|
1236
1370
|
encrypted = true;
|
|
1237
1371
|
} catch(e) {
|
|
1238
1372
|
// Decryption failed — return raw
|
|
1239
1373
|
}
|
|
1240
1374
|
}
|
|
1375
|
+
if (payload === null) payload = data.slice(off + 13, off + 13 + length);
|
|
1241
1376
|
|
|
1242
1377
|
records.push({
|
|
1243
1378
|
type: type,
|
package/src/secure_context.js
CHANGED
|
@@ -60,17 +60,28 @@ function makeX509FromDerOrPem(buf) {
|
|
|
60
60
|
}
|
|
61
61
|
|
|
62
62
|
function makePrivateKeyFromDerOrPem(buf, passphrase) {
|
|
63
|
+
// node:crypto rejects an explicit `passphrase: null` with ERR_INVALID_ARG_VALUE
|
|
64
|
+
// — for an unencrypted key the property must be ABSENT, not present-and-null.
|
|
65
|
+
// Callers that forward an optional option straight through would otherwise
|
|
66
|
+
// break every unencrypted key, which is the overwhelmingly common case.
|
|
67
|
+
let pass = (passphrase === null || passphrase === undefined) ? undefined : passphrase;
|
|
68
|
+
|
|
63
69
|
if (isPEM(buf)) {
|
|
64
|
-
|
|
70
|
+
let opts = { key: buf, format: 'pem' };
|
|
71
|
+
if (pass !== undefined) opts.passphrase = pass;
|
|
72
|
+
return crypto.createPrivateKey(opts);
|
|
65
73
|
} else {
|
|
66
74
|
let der = Buffer.from(buf), keyObj = null;
|
|
67
75
|
try {
|
|
68
|
-
keyObj = crypto.createPrivateKey({ key: der, format: 'der', type: 'pkcs8'
|
|
76
|
+
keyObj = crypto.createPrivateKey(Object.assign({ key: der, format: 'der', type: 'pkcs8' },
|
|
77
|
+
pass !== undefined ? { passphrase: pass } : {}));
|
|
69
78
|
} catch (e1) {
|
|
70
79
|
try {
|
|
71
|
-
keyObj = crypto.createPrivateKey({ key: der, format: 'der', type: 'pkcs1'
|
|
80
|
+
keyObj = crypto.createPrivateKey(Object.assign({ key: der, format: 'der', type: 'pkcs1' },
|
|
81
|
+
pass !== undefined ? { passphrase: pass } : {}));
|
|
72
82
|
} catch (e2) {
|
|
73
|
-
keyObj = crypto.createPrivateKey({ key: der, format: 'der', type: 'sec1'
|
|
83
|
+
keyObj = crypto.createPrivateKey(Object.assign({ key: der, format: 'der', type: 'sec1' },
|
|
84
|
+
pass !== undefined ? { passphrase: pass } : {}));
|
|
74
85
|
}
|
|
75
86
|
}
|
|
76
87
|
return keyObj;
|
|
@@ -106,6 +117,40 @@ function dedupeDerArray(arr) {
|
|
|
106
117
|
function createSecureContext(options) {
|
|
107
118
|
if (!options) options = {};
|
|
108
119
|
|
|
120
|
+
// Several key/certificate pairs — node:tls's arrays: `key` an array of
|
|
121
|
+
// keys (or { pem, passphrase }), `cert` an array of chains, one per key —
|
|
122
|
+
// for a server that holds certificates of more than one kind: an ML-DSA one
|
|
123
|
+
// for clients that ask for post-quantum authentication, an ECDSA or RSA one
|
|
124
|
+
// for everyone else. Each key is paired with the chain whose leaf carries
|
|
125
|
+
// its public key, in whatever order they were given; the first key's pair
|
|
126
|
+
// is the default, the others `alternates`. Which one a handshake presents
|
|
127
|
+
// is the client's signature_algorithms' to decide (tls_session).
|
|
128
|
+
if (Array.isArray(options.key) && options.key.length > 1) {
|
|
129
|
+
let certs = Array.isArray(options.cert) ? options.cert : [options.cert];
|
|
130
|
+
let contexts = options.key.map(function (k, i) {
|
|
131
|
+
let keyIn = (k && typeof k === 'object' && !Buffer.isBuffer(k) && !(k instanceof Uint8Array) && k.pem) ? k.pem : k;
|
|
132
|
+
let pass = (k && typeof k === 'object' && k.passphrase != null) ? k.passphrase : options.passphrase;
|
|
133
|
+
let pub = crypto.createPublicKey(makePrivateKeyFromDerOrPem(readMaybeFile(keyIn), pass)).export({ type: 'spki', format: 'der' });
|
|
134
|
+
let cert = null;
|
|
135
|
+
for (let j = 0; j < certs.length && cert === null; j++) {
|
|
136
|
+
let raw = readMaybeFile(certs[j]);
|
|
137
|
+
let first = isPEM(raw) ? splitPEMBlocks(raw.toString('utf8')).filter(function (b) { return b.type.indexOf('CERTIFICATE') !== -1; })[0] : { der: new Uint8Array(raw) };
|
|
138
|
+
if (!first) continue;
|
|
139
|
+
let leafPub = makeX509FromDerOrPem(first.der).publicKey.export({ type: 'spki', format: 'der' });
|
|
140
|
+
if (Buffer.compare(Buffer.from(leafPub), Buffer.from(pub)) === 0) cert = certs[j];
|
|
141
|
+
}
|
|
142
|
+
if (cert === null) throw new Error('createSecureContext: no certificate in `cert` for key ' + i);
|
|
143
|
+
return createSecureContext(Object.assign({}, options, { key: keyIn, passphrase: pass, cert: cert }));
|
|
144
|
+
});
|
|
145
|
+
let primary = contexts[0];
|
|
146
|
+
primary.alternates = contexts.slice(1).map(function (c) {
|
|
147
|
+
return { certificateChain: c.certificateChain, privateKey: c.privateKey, keyObj: c.keyObj, certObjs: c.certObjs };
|
|
148
|
+
});
|
|
149
|
+
return primary;
|
|
150
|
+
}
|
|
151
|
+
if (Array.isArray(options.key)) options = Object.assign({}, options, { key: options.key[0] && options.key[0].pem ? options.key[0].pem : options.key[0] });
|
|
152
|
+
if (Array.isArray(options.cert) && options.cert.length === 1) options = Object.assign({}, options, { cert: options.cert[0] });
|
|
153
|
+
|
|
109
154
|
// --- תעודות (כולל שרשרת בתוך cert אם קיימת) ---
|
|
110
155
|
let certBlocksDer = [];
|
|
111
156
|
let certObjs = [];
|
|
@@ -141,6 +186,20 @@ function createSecureContext(options) {
|
|
|
141
186
|
if (!privateKey) throw new Error('createSecureContext: missing private key.');
|
|
142
187
|
}
|
|
143
188
|
|
|
189
|
+
// The key must be the certificate's — node:tls throws on this too
|
|
190
|
+
// (KEY_VALUES_MISMATCH). Unchecked, a renewal that paired the new key with
|
|
191
|
+
// the old certificate went in without a word, and every handshake after it
|
|
192
|
+
// failed at the client's signature check.
|
|
193
|
+
if (keyObj && certObjs.length > 0) {
|
|
194
|
+
let fromKey = crypto.createPublicKey(keyObj).export({ type: 'spki', format: 'der' });
|
|
195
|
+
let fromCert = certObjs[0].publicKey.export({ type: 'spki', format: 'der' });
|
|
196
|
+
if (Buffer.compare(Buffer.from(fromKey), Buffer.from(fromCert)) !== 0) {
|
|
197
|
+
let e = new Error('createSecureContext: the private key does not match the certificate');
|
|
198
|
+
e.code = 'ERR_OSSL_X509_KEY_VALUES_MISMATCH';
|
|
199
|
+
throw e;
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
144
203
|
// --- CA (Trust store) ---
|
|
145
204
|
let ca = normalizeCA(options.ca);
|
|
146
205
|
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TLS Certificate Compression (RFC 8879), TLS 1.3 and DTLS 1.3.
|
|
3
|
+
*
|
|
4
|
+
* A certificate chain is most of what a server sends in its handshake —
|
|
5
|
+
* 3-5 KB for a public CA's chain, against a few hundred bytes for the rest.
|
|
6
|
+
* Compressed it is often half that or less: the certificates repeat names,
|
|
7
|
+
* extensions and OIDs. Over QUIC it decides how many round trips the
|
|
8
|
+
* handshake takes: before the client's address is validated a server may
|
|
9
|
+
* send three times what it received (RFC 9000 §8.1), ~3.6 KB for a 1200-byte
|
|
10
|
+
* Initial, and a chain that does not fit waits a round trip for the
|
|
11
|
+
* client's next packet. Browsers offer it (Chrome: brotli; Firefox: zlib,
|
|
12
|
+
* brotli, zstd).
|
|
13
|
+
*
|
|
14
|
+
* The client lists the algorithms it can decompress (compress_certificate,
|
|
15
|
+
* extension 27); the server, holding one it can compress with, sends
|
|
16
|
+
* CompressedCertificate (handshake type 25) in place of Certificate. The
|
|
17
|
+
* same works the other way for a client certificate, when the server's
|
|
18
|
+
* CertificateRequest carries the extension.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import zlib from 'node:zlib';
|
|
22
|
+
import crypto from 'node:crypto';
|
|
23
|
+
|
|
24
|
+
function brotliOptions(b) {
|
|
25
|
+
return { params: { [zlib.constants.BROTLI_PARAM_QUALITY]: 11, [zlib.constants.BROTLI_PARAM_SIZE_HINT]: b.length } };
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// RFC 8879 §3, and the IANA registry: zlib 1, brotli 2, zstd 3.
|
|
29
|
+
const ALGORITHMS = {
|
|
30
|
+
1: { name: 'zlib',
|
|
31
|
+
compress: (b) => zlib.deflateSync(b, { level: 9 }),
|
|
32
|
+
compressAsync: (b, cb) => zlib.deflate(b, { level: 9 }, cb),
|
|
33
|
+
decompress: (b, max) => zlib.inflateSync(b, { maxOutputLength: max }) },
|
|
34
|
+
2: { name: 'brotli',
|
|
35
|
+
compress: (b) => zlib.brotliCompressSync(b, brotliOptions(b)),
|
|
36
|
+
compressAsync: (b, cb) => zlib.brotliCompress(b, brotliOptions(b), cb),
|
|
37
|
+
decompress: (b, max) => zlib.brotliDecompressSync(b, { maxOutputLength: max }) },
|
|
38
|
+
};
|
|
39
|
+
// zstd where node:zlib has it (Node 22.15+, 23.8+).
|
|
40
|
+
if (typeof zlib.zstdCompressSync === 'function' && typeof zlib.zstdDecompressSync === 'function') {
|
|
41
|
+
ALGORITHMS[3] = { name: 'zstd',
|
|
42
|
+
compress: (b) => zlib.zstdCompressSync(b),
|
|
43
|
+
compressAsync: (b, cb) => zlib.zstdCompress(b, cb),
|
|
44
|
+
decompress: (b, max) => zlib.zstdDecompressSync(b, { maxOutputLength: max }) };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const BY_NAME = {};
|
|
48
|
+
for (const code in ALGORITHMS) BY_NAME[ALGORITHMS[code].name] = Number(code);
|
|
49
|
+
|
|
50
|
+
/** The algorithms this build can use, in its preference (best ratio first). */
|
|
51
|
+
function supported_cert_compression() {
|
|
52
|
+
return [2, 3, 1].filter((c) => !!ALGORITHMS[c]);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* An option naming algorithms — ['brotli', 'zlib'], codes, or true (all) /
|
|
57
|
+
* false (none) — as codes this build supports. Throws on a name it does not
|
|
58
|
+
* know; one it knows but cannot run here (zstd on an older Node) is left out.
|
|
59
|
+
*/
|
|
60
|
+
function parse_cert_compression(value, what) {
|
|
61
|
+
if (value === undefined || value === null) return null;
|
|
62
|
+
if (value === false) return [];
|
|
63
|
+
if (value === true) return supported_cert_compression();
|
|
64
|
+
if (!Array.isArray(value)) throw new TypeError((what || 'certificateCompression') + ' must be an array of algorithms (brotli, zlib, zstd), true or false');
|
|
65
|
+
const out = [];
|
|
66
|
+
for (const v of value) {
|
|
67
|
+
let code = typeof v === 'number' ? v : BY_NAME[String(v).toLowerCase()];
|
|
68
|
+
if (code === undefined && String(v).toLowerCase() === 'zstd') continue; // not in this Node
|
|
69
|
+
if (code === undefined || (code !== 1 && code !== 2 && code !== 3)) throw new TypeError((what || 'certificateCompression') + ': unknown algorithm ' + JSON.stringify(v));
|
|
70
|
+
if (ALGORITHMS[code] && out.indexOf(code) < 0) out.push(code);
|
|
71
|
+
}
|
|
72
|
+
return out;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// A chain is compressed once per algorithm: the server sends the same bytes
|
|
76
|
+
// on every handshake, and brotli at quality 11 is not cheap. Keyed by a hash
|
|
77
|
+
// of the uncompressed message; bounded.
|
|
78
|
+
const cache = new Map();
|
|
79
|
+
const CACHE_MAX = 64;
|
|
80
|
+
|
|
81
|
+
function cache_key(body, alg) {
|
|
82
|
+
return alg + ':' + crypto.createHash('sha256').update(body).digest('hex');
|
|
83
|
+
}
|
|
84
|
+
function cache_put(key, out) {
|
|
85
|
+
cache.set(key, out);
|
|
86
|
+
if (cache.size > CACHE_MAX) cache.delete(cache.keys().next().value);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** `body` (an encoded Certificate message body) compressed with `alg`. */
|
|
90
|
+
function compress_certificate_body(body, alg) {
|
|
91
|
+
const a = ALGORITHMS[alg];
|
|
92
|
+
if (!a) return null;
|
|
93
|
+
const key = cache_key(body, alg);
|
|
94
|
+
let out = cache.get(key);
|
|
95
|
+
if (out) return out;
|
|
96
|
+
out = new Uint8Array(a.compress(Buffer.from(body)));
|
|
97
|
+
cache_put(key, out);
|
|
98
|
+
return out;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// The same, ahead of the first handshake and off the event loop (zlib's
|
|
102
|
+
// thread pool): what that handshake would have done synchronously — brotli
|
|
103
|
+
// at quality 11 is 4 ms for a 400-byte chain and 18 ms for a 9 KB one, the
|
|
104
|
+
// first time a server sends it, while that client waits — is in the cache
|
|
105
|
+
// when it comes. A chain already there, or on its way, is not done twice.
|
|
106
|
+
// callback() when every algorithm's is in; a failure leaves that one to
|
|
107
|
+
// the handshake, as before.
|
|
108
|
+
const pending = new Map(); // key → callbacks waiting on it
|
|
109
|
+
function precompress_certificate_body(body, algs, callback) {
|
|
110
|
+
let left = 1;
|
|
111
|
+
const done = () => { if (--left === 0 && callback) callback(); };
|
|
112
|
+
for (const alg of algs) {
|
|
113
|
+
const a = ALGORITHMS[alg];
|
|
114
|
+
if (!a || !a.compressAsync) continue;
|
|
115
|
+
const key = cache_key(body, alg);
|
|
116
|
+
if (cache.has(key)) continue;
|
|
117
|
+
left++;
|
|
118
|
+
if (pending.has(key)) { pending.get(key).push(done); continue; }
|
|
119
|
+
pending.set(key, [done]);
|
|
120
|
+
a.compressAsync(Buffer.from(body), (err, out) => {
|
|
121
|
+
if (!err && !cache.has(key)) cache_put(key, new Uint8Array(out));
|
|
122
|
+
const waiters = pending.get(key) || []; pending.delete(key);
|
|
123
|
+
for (const w of waiters) w();
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
done();
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* A CompressedCertificate's contents back to the Certificate message body,
|
|
131
|
+
* or null: an algorithm not ours, data that does not decompress, or a
|
|
132
|
+
* length other than the one declared (RFC 8879 §4: bad_certificate). Never
|
|
133
|
+
* more than the declared length is produced — nor more than `limit`.
|
|
134
|
+
*/
|
|
135
|
+
function decompress_certificate_body(alg, data, uncompressedLength, limit) {
|
|
136
|
+
const a = ALGORITHMS[alg];
|
|
137
|
+
if (!a) return null;
|
|
138
|
+
if (!(uncompressedLength > 0) || (limit && uncompressedLength > limit)) return null;
|
|
139
|
+
let out;
|
|
140
|
+
try { out = a.decompress(Buffer.from(data), uncompressedLength); }
|
|
141
|
+
catch (e) { return null; }
|
|
142
|
+
if (out.length !== uncompressedLength) return null;
|
|
143
|
+
return new Uint8Array(out);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function cert_compression_name(alg) {
|
|
147
|
+
return ALGORITHMS[alg] ? ALGORITHMS[alg].name : String(alg);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export {
|
|
151
|
+
supported_cert_compression,
|
|
152
|
+
parse_cert_compression,
|
|
153
|
+
compress_certificate_body,
|
|
154
|
+
precompress_certificate_body,
|
|
155
|
+
decompress_certificate_body,
|
|
156
|
+
cert_compression_name,
|
|
157
|
+
};
|