@agentdance/node-webrtc-srtp 1.0.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/dist/auth.d.ts +27 -0
- package/dist/auth.d.ts.map +1 -0
- package/dist/auth.js +46 -0
- package/dist/auth.js.map +1 -0
- package/dist/cipher.d.ts +24 -0
- package/dist/cipher.d.ts.map +1 -0
- package/dist/cipher.js +61 -0
- package/dist/cipher.js.map +1 -0
- package/dist/context.d.ts +30 -0
- package/dist/context.d.ts.map +1 -0
- package/dist/context.js +104 -0
- package/dist/context.js.map +1 -0
- package/dist/gcm.d.ts +14 -0
- package/dist/gcm.d.ts.map +1 -0
- package/dist/gcm.js +151 -0
- package/dist/gcm.js.map +1 -0
- package/dist/index.d.ts +10 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +10 -0
- package/dist/index.js.map +1 -0
- package/dist/protect.d.ts +21 -0
- package/dist/protect.d.ts.map +1 -0
- package/dist/protect.js +129 -0
- package/dist/protect.js.map +1 -0
- package/dist/replay.d.ts +22 -0
- package/dist/replay.d.ts.map +1 -0
- package/dist/replay.js +56 -0
- package/dist/replay.js.map +1 -0
- package/dist/types.d.ts +40 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +11 -0
- package/dist/types.js.map +1 -0
- package/dist/unprotect.d.ts +14 -0
- package/dist/unprotect.d.ts.map +1 -0
- package/dist/unprotect.js +156 -0
- package/dist/unprotect.js.map +1 -0
- package/package.json +57 -0
- package/src/auth.ts +61 -0
- package/src/cipher.ts +68 -0
- package/src/context.ts +130 -0
- package/src/gcm.ts +174 -0
- package/src/index.ts +18 -0
- package/src/protect.ts +160 -0
- package/src/replay.ts +57 -0
- package/src/types.ts +55 -0
- package/src/unprotect.ts +174 -0
package/src/context.ts
ADDED
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import { createCipheriv } from 'node:crypto';
|
|
2
|
+
import {
|
|
3
|
+
ProtectionProfile,
|
|
4
|
+
SrtpKeyingMaterial,
|
|
5
|
+
SrtpContext,
|
|
6
|
+
SrtcpContext,
|
|
7
|
+
} from './types.js';
|
|
8
|
+
import { aes128cmKeystream } from './cipher.js';
|
|
9
|
+
import { ReplayWindow } from './replay.js';
|
|
10
|
+
|
|
11
|
+
// ---------------------------------------------------------------------------
|
|
12
|
+
// AES-128-CM PRF (RFC 3711 §4.3.1)
|
|
13
|
+
// ---------------------------------------------------------------------------
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Derive a session key using the AES-CM key derivation function.
|
|
17
|
+
*
|
|
18
|
+
* The PRF key is the master key; the PRF input (IV) is:
|
|
19
|
+
* PRF_input = master_salt XOR (label * 2^48) XOR (r * 2^???)
|
|
20
|
+
*
|
|
21
|
+
* For KDR=0 (the common case), r=0 and the second XOR term vanishes.
|
|
22
|
+
* The label is placed at byte offset 7 in the 14-byte salt (bit position 48).
|
|
23
|
+
*
|
|
24
|
+
* RFC 3711 §4.3.1:
|
|
25
|
+
* x = label * 2^48 (label occupies bits 48-55 of the 112-bit salt field)
|
|
26
|
+
*
|
|
27
|
+
* @param masterKey 16-byte AES master key
|
|
28
|
+
* @param masterSalt 14-byte master salt
|
|
29
|
+
* @param label 0x00=enc, 0x01=auth, 0x02=salt
|
|
30
|
+
* @param length Desired output length in bytes
|
|
31
|
+
* @param r Key derivation rate index (default 0)
|
|
32
|
+
*/
|
|
33
|
+
export function deriveSessionKey(
|
|
34
|
+
masterKey: Buffer,
|
|
35
|
+
masterSalt: Buffer,
|
|
36
|
+
label: number,
|
|
37
|
+
length: number,
|
|
38
|
+
r: bigint = 0n,
|
|
39
|
+
): Buffer {
|
|
40
|
+
if (masterSalt.length !== 14) throw new RangeError('masterSalt must be 14 bytes');
|
|
41
|
+
|
|
42
|
+
// Build the 112-bit (14-byte) x value, then zero-pad to 16 bytes (right-pad
|
|
43
|
+
// with two 0x00 bytes) to form the IV for AES-CM.
|
|
44
|
+
//
|
|
45
|
+
// x = master_salt XOR (label << 48) XOR (r << kdr_shift)
|
|
46
|
+
// For KDR = 0 the r term is zero.
|
|
47
|
+
//
|
|
48
|
+
// label << 48 places the label byte at offset 6 (0-indexed) in the 14-byte
|
|
49
|
+
// big-endian integer.
|
|
50
|
+
const x = Buffer.from(masterSalt);
|
|
51
|
+
|
|
52
|
+
// XOR in label at byte offset 7 (label is at bit position 48 of the
|
|
53
|
+
// 112-bit big-endian value, i.e. byte index 7 counting from the MSB).
|
|
54
|
+
x.writeUInt8(x.readUInt8(7) ^ (label & 0xff), 7);
|
|
55
|
+
|
|
56
|
+
// If r != 0 we XOR it into the low 8 bytes of x (bytes 6..13).
|
|
57
|
+
// KDR=0 is the common case so this branch is rarely taken.
|
|
58
|
+
if (r !== 0n) {
|
|
59
|
+
for (let i = 0; i < 8; i++) {
|
|
60
|
+
const shift = BigInt((7 - i) * 8);
|
|
61
|
+
const off = 6 + i;
|
|
62
|
+
x.writeUInt8(x.readUInt8(off) ^ Number((r >> shift) & 0xffn), off);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Zero-pad to 16 bytes (append two 0x00 bytes) → this is the AES-CM IV
|
|
67
|
+
const iv = Buffer.alloc(16, 0);
|
|
68
|
+
x.copy(iv, 0); // x occupies bytes 0..13; bytes 14,15 remain 0
|
|
69
|
+
|
|
70
|
+
return aes128cmKeystream(masterKey, iv, length);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// ---------------------------------------------------------------------------
|
|
74
|
+
// Tag length helpers
|
|
75
|
+
// ---------------------------------------------------------------------------
|
|
76
|
+
|
|
77
|
+
function authTagLength(profile: ProtectionProfile): 10 | 4 {
|
|
78
|
+
return profile === ProtectionProfile.AES_128_CM_HMAC_SHA1_32 ? 4 : 10;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// ---------------------------------------------------------------------------
|
|
82
|
+
// Context factories
|
|
83
|
+
// ---------------------------------------------------------------------------
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Derive all three SRTP session keys from the supplied master keying material
|
|
87
|
+
* and return an initialised SrtpContext.
|
|
88
|
+
*/
|
|
89
|
+
export function createSrtpContext(material: SrtpKeyingMaterial): SrtpContext {
|
|
90
|
+
const { masterKey, masterSalt, profile } = material;
|
|
91
|
+
|
|
92
|
+
const sessionEncKey = deriveSessionKey(masterKey, masterSalt, 0x00, 16);
|
|
93
|
+
const sessionSaltKey = deriveSessionKey(masterKey, masterSalt, 0x02, 14);
|
|
94
|
+
// Auth key is only meaningful for CM profiles; for GCM we still derive it
|
|
95
|
+
// (harmless) but it won't be used for packet authentication.
|
|
96
|
+
const sessionAuthKey = deriveSessionKey(masterKey, masterSalt, 0x01, 20);
|
|
97
|
+
|
|
98
|
+
return {
|
|
99
|
+
profile,
|
|
100
|
+
sessionEncKey,
|
|
101
|
+
sessionAuthKey,
|
|
102
|
+
sessionSaltKey,
|
|
103
|
+
index: 0n,
|
|
104
|
+
rolloverCounter: 0,
|
|
105
|
+
lastSeq: -1,
|
|
106
|
+
replayWindow: new ReplayWindow(),
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Derive all three SRTCP session keys and return an initialised SrtcpContext.
|
|
112
|
+
*/
|
|
113
|
+
export function createSrtcpContext(material: SrtpKeyingMaterial): SrtcpContext {
|
|
114
|
+
const { masterKey, masterSalt, profile } = material;
|
|
115
|
+
|
|
116
|
+
// SRTCP uses separate labels (same label values, different contexts per
|
|
117
|
+
// spec; in practice the same KDF is used with the same master key/salt).
|
|
118
|
+
const sessionEncKey = deriveSessionKey(masterKey, masterSalt, 0x00, 16);
|
|
119
|
+
const sessionSaltKey = deriveSessionKey(masterKey, masterSalt, 0x02, 14);
|
|
120
|
+
const sessionAuthKey = deriveSessionKey(masterKey, masterSalt, 0x01, 20);
|
|
121
|
+
|
|
122
|
+
return {
|
|
123
|
+
profile,
|
|
124
|
+
sessionEncKey,
|
|
125
|
+
sessionAuthKey,
|
|
126
|
+
sessionSaltKey,
|
|
127
|
+
index: 0,
|
|
128
|
+
replayWindow: new ReplayWindow(),
|
|
129
|
+
};
|
|
130
|
+
}
|
package/src/gcm.ts
ADDED
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
import { createCipheriv, createDecipheriv } from 'node:crypto';
|
|
2
|
+
import { SrtpContext } from './types.js';
|
|
3
|
+
|
|
4
|
+
// ---------------------------------------------------------------------------
|
|
5
|
+
// AES-128-GCM (RFC 7714)
|
|
6
|
+
// ---------------------------------------------------------------------------
|
|
7
|
+
|
|
8
|
+
// GCM auth tag is always 16 bytes (128-bit).
|
|
9
|
+
const GCM_TAG_LENGTH = 16;
|
|
10
|
+
// GCM nonce/IV is 12 bytes.
|
|
11
|
+
const GCM_IV_LENGTH = 12;
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Build the 12-byte GCM IV from the 12-byte salt, SSRC, and packet index.
|
|
15
|
+
*
|
|
16
|
+
* RFC 7714 §8.1:
|
|
17
|
+
* IV = salt XOR (SSRC placed at bytes 4..7) XOR (index placed at bytes 4..11)
|
|
18
|
+
*
|
|
19
|
+
* More precisely for SRTP (48-bit index):
|
|
20
|
+
* - Bytes 0..3: salt[0..3] XOR 0 XOR 0 (no SSRC/index contribution)
|
|
21
|
+
* - Bytes 4..7: salt[4..7] XOR SSRC_be32 XOR index_hi32
|
|
22
|
+
* - Bytes 8..11: salt[8..11] XOR 0 XOR index_lo32
|
|
23
|
+
*
|
|
24
|
+
* Wait – GCM salt is typically 12 bytes, not 14. When used inside the
|
|
25
|
+
* standard AES-CM derivation pipeline the 14-byte session salt is trimmed
|
|
26
|
+
* to 12 bytes by dropping the two trailing zero bytes (they were zero-padded
|
|
27
|
+
* anyway in the PRF output, but in practice the caller passes the full 14).
|
|
28
|
+
* We take the first 12 bytes of the supplied salt.
|
|
29
|
+
*/
|
|
30
|
+
function buildGcmIv(salt: Buffer, ssrc: number, index: bigint): Buffer {
|
|
31
|
+
// Use first 12 bytes; remaining 2 are the PRF zero-padding.
|
|
32
|
+
const iv = Buffer.alloc(GCM_IV_LENGTH, 0);
|
|
33
|
+
salt.copy(iv, 0, 0, GCM_IV_LENGTH);
|
|
34
|
+
|
|
35
|
+
// XOR SSRC into bytes 4..7
|
|
36
|
+
iv.writeUInt32BE(iv.readUInt32BE(4) ^ (ssrc >>> 0), 4);
|
|
37
|
+
|
|
38
|
+
// XOR 48-bit index (big-endian 6 bytes) into bytes 6..11.
|
|
39
|
+
// Store index into a temporary 8-byte buffer; bytes [2..7] hold the 48-bit value.
|
|
40
|
+
const idxBytes = Buffer.allocUnsafe(8);
|
|
41
|
+
idxBytes.writeBigUInt64BE(index & 0xffffffffffffn, 0);
|
|
42
|
+
// bytes 6..7 overlap with the SSRC field – XOR as a 16-bit word
|
|
43
|
+
iv.writeUInt16BE(iv.readUInt16BE(6) ^ idxBytes.readUInt16BE(2), 6);
|
|
44
|
+
// bytes 8..11 – XOR as a 32-bit word
|
|
45
|
+
iv.writeUInt32BE(iv.readUInt32BE(8) ^ idxBytes.readUInt32BE(4), 8);
|
|
46
|
+
|
|
47
|
+
return iv;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// ---------------------------------------------------------------------------
|
|
51
|
+
// Parse minimal RTP header to extract SSRC and payload offset
|
|
52
|
+
// ---------------------------------------------------------------------------
|
|
53
|
+
|
|
54
|
+
function parseRtpHeader(packet: Buffer): { ssrc: number; headerLen: number } {
|
|
55
|
+
if (packet.length < 12) throw new RangeError('RTP packet too short');
|
|
56
|
+
const cc = packet[0]! & 0x0f;
|
|
57
|
+
const x = (packet[0]! & 0x10) !== 0;
|
|
58
|
+
let headerLen = 12 + cc * 4;
|
|
59
|
+
if (x) {
|
|
60
|
+
if (packet.length < headerLen + 4) throw new RangeError('RTP header extension truncated');
|
|
61
|
+
const extLen = packet.readUInt16BE(headerLen + 2);
|
|
62
|
+
headerLen += 4 + extLen * 4;
|
|
63
|
+
}
|
|
64
|
+
const ssrc = packet.readUInt32BE(8);
|
|
65
|
+
return { ssrc, headerLen };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// ---------------------------------------------------------------------------
|
|
69
|
+
// GCM protect / unprotect
|
|
70
|
+
// ---------------------------------------------------------------------------
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Encrypt and authenticate an SRTP packet using AES-128-GCM.
|
|
74
|
+
*
|
|
75
|
+
* Packet layout after protection:
|
|
76
|
+
* RTP Header (AAD, unchanged) | Encrypted Payload | GCM Auth Tag (16 bytes)
|
|
77
|
+
*/
|
|
78
|
+
export function gcmSrtpProtect(ctx: SrtpContext, rtpPacket: Buffer): Buffer {
|
|
79
|
+
const { ssrc, headerLen } = parseRtpHeader(rtpPacket);
|
|
80
|
+
const seq = rtpPacket.readUInt16BE(2);
|
|
81
|
+
|
|
82
|
+
// Compute packet index and update context state.
|
|
83
|
+
const index = computeIndex(ctx, seq);
|
|
84
|
+
|
|
85
|
+
const iv = buildGcmIv(ctx.sessionSaltKey, ssrc, index);
|
|
86
|
+
const header = rtpPacket.subarray(0, headerLen);
|
|
87
|
+
const payload = rtpPacket.subarray(headerLen);
|
|
88
|
+
|
|
89
|
+
const cipher = createCipheriv('aes-128-gcm', ctx.sessionEncKey, iv);
|
|
90
|
+
cipher.setAAD(header);
|
|
91
|
+
const encrypted = Buffer.concat([cipher.update(payload), cipher.final()]);
|
|
92
|
+
const tag = cipher.getAuthTag();
|
|
93
|
+
|
|
94
|
+
// Update context
|
|
95
|
+
ctx.index = index;
|
|
96
|
+
ctx.rolloverCounter = Number(index >> 16n) >>> 0;
|
|
97
|
+
ctx.lastSeq = seq;
|
|
98
|
+
|
|
99
|
+
return Buffer.concat([header, encrypted, tag]);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Authenticate and decrypt a GCM-protected SRTP packet.
|
|
104
|
+
* Returns null if authentication fails or replay is detected.
|
|
105
|
+
*/
|
|
106
|
+
export function gcmSrtpUnprotect(ctx: SrtpContext, srtpPacket: Buffer): Buffer | null {
|
|
107
|
+
if (srtpPacket.length < 12 + GCM_TAG_LENGTH) return null;
|
|
108
|
+
|
|
109
|
+
const { ssrc, headerLen } = parseRtpHeader(srtpPacket);
|
|
110
|
+
const seq = srtpPacket.readUInt16BE(2);
|
|
111
|
+
|
|
112
|
+
const index = estimateIndex(ctx, seq);
|
|
113
|
+
|
|
114
|
+
if (!ctx.replayWindow.check(index)) return null;
|
|
115
|
+
|
|
116
|
+
const header = srtpPacket.subarray(0, headerLen);
|
|
117
|
+
const encryptedPayload = srtpPacket.subarray(headerLen, srtpPacket.length - GCM_TAG_LENGTH);
|
|
118
|
+
const tag = srtpPacket.subarray(srtpPacket.length - GCM_TAG_LENGTH);
|
|
119
|
+
|
|
120
|
+
const iv = buildGcmIv(ctx.sessionSaltKey, ssrc, index);
|
|
121
|
+
|
|
122
|
+
try {
|
|
123
|
+
const decipher = createDecipheriv('aes-128-gcm', ctx.sessionEncKey, iv);
|
|
124
|
+
decipher.setAAD(header);
|
|
125
|
+
decipher.setAuthTag(tag);
|
|
126
|
+
const decrypted = Buffer.concat([decipher.update(encryptedPayload), decipher.final()]);
|
|
127
|
+
|
|
128
|
+
ctx.replayWindow.update(index);
|
|
129
|
+
ctx.index = index;
|
|
130
|
+
ctx.rolloverCounter = Number(index >> 16n) >>> 0;
|
|
131
|
+
ctx.lastSeq = seq;
|
|
132
|
+
|
|
133
|
+
return Buffer.concat([header, decrypted]);
|
|
134
|
+
} catch {
|
|
135
|
+
return null;
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// ---------------------------------------------------------------------------
|
|
140
|
+
// Index helpers (shared logic – duplicated from protect/unprotect to keep
|
|
141
|
+
// gcm.ts self-contained; in a real project you'd factor these out)
|
|
142
|
+
// ---------------------------------------------------------------------------
|
|
143
|
+
|
|
144
|
+
function computeIndex(ctx: SrtpContext, seq: number): bigint {
|
|
145
|
+
if (ctx.lastSeq === -1) {
|
|
146
|
+
return BigInt(ctx.rolloverCounter) << 16n | BigInt(seq);
|
|
147
|
+
}
|
|
148
|
+
const roc = BigInt(ctx.rolloverCounter);
|
|
149
|
+
const lastSeq = ctx.lastSeq;
|
|
150
|
+
|
|
151
|
+
// If seq wraps forward (65535 → 0), increment ROC
|
|
152
|
+
if (seq < lastSeq && lastSeq - seq > 0x8000) {
|
|
153
|
+
return (roc + 1n) << 16n | BigInt(seq);
|
|
154
|
+
}
|
|
155
|
+
return roc << 16n | BigInt(seq);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function estimateIndex(ctx: SrtpContext, seq: number): bigint {
|
|
159
|
+
if (ctx.lastSeq === -1) {
|
|
160
|
+
return BigInt(ctx.rolloverCounter) << 16n | BigInt(seq);
|
|
161
|
+
}
|
|
162
|
+
// RFC 3711 §3.3.1 index estimation
|
|
163
|
+
const v = BigInt(ctx.rolloverCounter);
|
|
164
|
+
const diff = seq - ctx.lastSeq;
|
|
165
|
+
|
|
166
|
+
if (diff > 0x8000) {
|
|
167
|
+
// seq is much less than lastSeq → different ROC (wrap back?)
|
|
168
|
+
return (v === 0n ? 0n : v - 1n) << 16n | BigInt(seq);
|
|
169
|
+
} else if (diff < -0x8000) {
|
|
170
|
+
// seq is much greater → ROC incremented
|
|
171
|
+
return (v + 1n) << 16n | BigInt(seq);
|
|
172
|
+
}
|
|
173
|
+
return v << 16n | BigInt(seq);
|
|
174
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
// Public API – re-export everything from the srtp package.
|
|
2
|
+
|
|
3
|
+
export { ProtectionProfile } from './types.js';
|
|
4
|
+
export type { SrtpKeyingMaterial, SrtpContext, SrtcpContext } from './types.js';
|
|
5
|
+
|
|
6
|
+
export { createSrtpContext, createSrtcpContext, deriveSessionKey } from './context.js';
|
|
7
|
+
|
|
8
|
+
export { aes128cmKeystream, computeSrtpIv, computeSrtcpIv } from './cipher.js';
|
|
9
|
+
|
|
10
|
+
export { computeSrtpAuthTag, computeSrtcpAuthTag } from './auth.js';
|
|
11
|
+
|
|
12
|
+
export { gcmSrtpProtect, gcmSrtpUnprotect } from './gcm.js';
|
|
13
|
+
|
|
14
|
+
export { ReplayWindow } from './replay.js';
|
|
15
|
+
|
|
16
|
+
export { srtpProtect, srtcpProtect } from './protect.js';
|
|
17
|
+
|
|
18
|
+
export { srtpUnprotect, srtcpUnprotect } from './unprotect.js';
|
package/src/protect.ts
ADDED
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
import { SrtpContext, SrtcpContext, ProtectionProfile } from './types.js';
|
|
2
|
+
import { aes128cmKeystream, computeSrtpIv, computeSrtcpIv } from './cipher.js';
|
|
3
|
+
import { computeSrtpAuthTag, computeSrtcpAuthTag } from './auth.js';
|
|
4
|
+
import { gcmSrtpProtect } from './gcm.js';
|
|
5
|
+
|
|
6
|
+
// ---------------------------------------------------------------------------
|
|
7
|
+
// Helpers
|
|
8
|
+
// ---------------------------------------------------------------------------
|
|
9
|
+
|
|
10
|
+
/** Number of authentication-tag bytes for a given profile. */
|
|
11
|
+
function tagLength(profile: ProtectionProfile): 10 | 4 {
|
|
12
|
+
return profile === ProtectionProfile.AES_128_CM_HMAC_SHA1_32 ? 4 : 10;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** Parse the minimum fixed RTP header fields we need. */
|
|
16
|
+
function parseRtpHeader(pkt: Buffer): { seq: number; ssrc: number; headerLen: number } {
|
|
17
|
+
if (pkt.length < 12) throw new RangeError('RTP packet too short (< 12 bytes)');
|
|
18
|
+
const cc = pkt[0]! & 0x0f;
|
|
19
|
+
const x = (pkt[0]! & 0x10) !== 0;
|
|
20
|
+
let headerLen = 12 + cc * 4;
|
|
21
|
+
if (x) {
|
|
22
|
+
if (pkt.length < headerLen + 4) throw new RangeError('RTP extension header truncated');
|
|
23
|
+
const extLen = pkt.readUInt16BE(headerLen + 2);
|
|
24
|
+
headerLen += 4 + extLen * 4;
|
|
25
|
+
}
|
|
26
|
+
return {
|
|
27
|
+
seq: pkt.readUInt16BE(2),
|
|
28
|
+
ssrc: pkt.readUInt32BE(8),
|
|
29
|
+
headerLen,
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Parse the minimum fixed RTCP header fields. */
|
|
34
|
+
function parseRtcpHeader(pkt: Buffer): { ssrc: number } {
|
|
35
|
+
if (pkt.length < 8) throw new RangeError('RTCP packet too short (< 8 bytes)');
|
|
36
|
+
return { ssrc: pkt.readUInt32BE(4) };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Advance the packet index (and ROC) for a sender-side protect call.
|
|
41
|
+
* The index increments monotonically; ROC increments whenever the 16-bit
|
|
42
|
+
* sequence counter wraps.
|
|
43
|
+
*/
|
|
44
|
+
function nextSrtpIndex(ctx: SrtpContext, seq: number): bigint {
|
|
45
|
+
if (ctx.lastSeq === -1) {
|
|
46
|
+
// First packet
|
|
47
|
+
return BigInt(ctx.rolloverCounter) << 16n | BigInt(seq);
|
|
48
|
+
}
|
|
49
|
+
const roc = BigInt(ctx.rolloverCounter);
|
|
50
|
+
const lastSeq = ctx.lastSeq;
|
|
51
|
+
|
|
52
|
+
// Detect forward wrap-around (65535 → 0)
|
|
53
|
+
if (seq < lastSeq && lastSeq - seq > 0x8000) {
|
|
54
|
+
return (roc + 1n) << 16n | BigInt(seq);
|
|
55
|
+
}
|
|
56
|
+
return roc << 16n | BigInt(seq);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// ---------------------------------------------------------------------------
|
|
60
|
+
// SRTP protect (RFC 3711 §3.1)
|
|
61
|
+
// ---------------------------------------------------------------------------
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Protect (encrypt + authenticate) an RTP packet.
|
|
65
|
+
*
|
|
66
|
+
* Output layout:
|
|
67
|
+
* RTP Header (unchanged) | Encrypted Payload | Auth Tag (10 or 4 bytes)
|
|
68
|
+
*
|
|
69
|
+
* GCM profiles are handled by delegating to `gcmSrtpProtect`.
|
|
70
|
+
*/
|
|
71
|
+
export function srtpProtect(ctx: SrtpContext, rtpPacket: Buffer): Buffer {
|
|
72
|
+
if (
|
|
73
|
+
ctx.profile === ProtectionProfile.AES_128_GCM ||
|
|
74
|
+
ctx.profile === ProtectionProfile.AES_256_GCM
|
|
75
|
+
) {
|
|
76
|
+
return gcmSrtpProtect(ctx, rtpPacket);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const { seq, ssrc, headerLen } = parseRtpHeader(rtpPacket);
|
|
80
|
+
const index = nextSrtpIndex(ctx, seq);
|
|
81
|
+
|
|
82
|
+
const header = rtpPacket.subarray(0, headerLen);
|
|
83
|
+
const payload = rtpPacket.subarray(headerLen);
|
|
84
|
+
|
|
85
|
+
// 1. Encrypt the payload with AES-128-CM
|
|
86
|
+
const iv = computeSrtpIv(ctx.sessionSaltKey, ssrc, index);
|
|
87
|
+
const keystream = aes128cmKeystream(ctx.sessionEncKey, iv, payload.length);
|
|
88
|
+
const encryptedPayload = Buffer.allocUnsafe(payload.length);
|
|
89
|
+
for (let i = 0; i < payload.length; i++) {
|
|
90
|
+
encryptedPayload[i] = payload[i]! ^ keystream[i]!;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// 2. Compute HMAC-SHA1 auth tag over header || encrypted_payload || ROC
|
|
94
|
+
const roc = Number(index >> 16n) >>> 0;
|
|
95
|
+
const tag = computeSrtpAuthTag(
|
|
96
|
+
ctx.sessionAuthKey,
|
|
97
|
+
header,
|
|
98
|
+
encryptedPayload,
|
|
99
|
+
roc,
|
|
100
|
+
tagLength(ctx.profile),
|
|
101
|
+
);
|
|
102
|
+
|
|
103
|
+
// 3. Update context state
|
|
104
|
+
ctx.index = index;
|
|
105
|
+
ctx.rolloverCounter = roc;
|
|
106
|
+
ctx.lastSeq = seq;
|
|
107
|
+
|
|
108
|
+
return Buffer.concat([header, encryptedPayload, tag]);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// ---------------------------------------------------------------------------
|
|
112
|
+
// SRTCP protect (RFC 3711 §3.4)
|
|
113
|
+
// ---------------------------------------------------------------------------
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Protect (encrypt + authenticate) an RTCP packet.
|
|
117
|
+
*
|
|
118
|
+
* Output layout:
|
|
119
|
+
* RTCP Header (8 bytes, unencrypted) |
|
|
120
|
+
* Encrypted Remainder |
|
|
121
|
+
* E (1 bit, always 1) | SRTCP Index (31 bits) |
|
|
122
|
+
* Auth Tag (10 bytes)
|
|
123
|
+
*/
|
|
124
|
+
export function srtcpProtect(ctx: SrtcpContext, rtcpPacket: Buffer): Buffer {
|
|
125
|
+
if (rtcpPacket.length < 8) throw new RangeError('RTCP packet too short');
|
|
126
|
+
|
|
127
|
+
const { ssrc } = parseRtcpHeader(rtcpPacket);
|
|
128
|
+
|
|
129
|
+
// Increment and clamp to 31 bits
|
|
130
|
+
const index = (ctx.index + 1) & 0x7fffffff;
|
|
131
|
+
ctx.index = index;
|
|
132
|
+
|
|
133
|
+
// First 8 bytes of RTCP are left unencrypted (fixed header).
|
|
134
|
+
const header = rtcpPacket.subarray(0, 8);
|
|
135
|
+
const rest = rtcpPacket.subarray(8);
|
|
136
|
+
|
|
137
|
+
// Encrypt the rest with AES-128-CM
|
|
138
|
+
const iv = computeSrtcpIv(ctx.sessionSaltKey, ssrc, index);
|
|
139
|
+
const keystream = aes128cmKeystream(ctx.sessionEncKey, iv, rest.length);
|
|
140
|
+
const encrypted = Buffer.allocUnsafe(rest.length);
|
|
141
|
+
for (let i = 0; i < rest.length; i++) {
|
|
142
|
+
encrypted[i] = rest[i]! ^ keystream[i]!;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// Build E || SRTCP_index word (E=1 means packet is encrypted)
|
|
146
|
+
const eSrtcpIndex = 0x80000000 | (index & 0x7fffffff);
|
|
147
|
+
const indexBuf = Buffer.allocUnsafe(4);
|
|
148
|
+
indexBuf.writeUInt32BE(eSrtcpIndex >>> 0, 0);
|
|
149
|
+
|
|
150
|
+
// Auth tag covers: header || encrypted_rest || E_SRTCP_index
|
|
151
|
+
const packetForAuth = Buffer.concat([header, encrypted]);
|
|
152
|
+
const tag = computeSrtcpAuthTag(
|
|
153
|
+
ctx.sessionAuthKey,
|
|
154
|
+
packetForAuth,
|
|
155
|
+
eSrtcpIndex,
|
|
156
|
+
tagLength(ctx.profile),
|
|
157
|
+
);
|
|
158
|
+
|
|
159
|
+
return Buffer.concat([header, encrypted, indexBuf, tag]);
|
|
160
|
+
}
|
package/src/replay.ts
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* RFC 3711 Section 3.3.2 – Sliding window replay protection.
|
|
3
|
+
* The window tracks the highest received index and a bitmask of the
|
|
4
|
+
* WINDOW_SIZE packets below it.
|
|
5
|
+
*/
|
|
6
|
+
export class ReplayWindow {
|
|
7
|
+
private top: bigint = -1n;
|
|
8
|
+
private bitmask: bigint = 0n;
|
|
9
|
+
private readonly windowSize: bigint;
|
|
10
|
+
|
|
11
|
+
constructor(windowSize: bigint = 64n) {
|
|
12
|
+
this.windowSize = windowSize;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Returns true if the packet index is acceptable (not replayed, inside window
|
|
17
|
+
* or ahead of it).
|
|
18
|
+
*/
|
|
19
|
+
check(index: bigint): boolean {
|
|
20
|
+
if (this.top === -1n) {
|
|
21
|
+
// Window not yet initialised – accept any packet.
|
|
22
|
+
return true;
|
|
23
|
+
}
|
|
24
|
+
if (index > this.top) {
|
|
25
|
+
// Ahead of the window – always accept.
|
|
26
|
+
return true;
|
|
27
|
+
}
|
|
28
|
+
const diff = this.top - index;
|
|
29
|
+
if (diff >= this.windowSize) {
|
|
30
|
+
// Too old – outside the window.
|
|
31
|
+
return false;
|
|
32
|
+
}
|
|
33
|
+
// Inside the window – accept only if not already seen.
|
|
34
|
+
const bit = 1n << diff;
|
|
35
|
+
return (this.bitmask & bit) === 0n;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Mark index as received and advance the window top if necessary.
|
|
40
|
+
* Must only be called after a successful auth-tag check.
|
|
41
|
+
*/
|
|
42
|
+
update(index: bigint): void {
|
|
43
|
+
if (index > this.top) {
|
|
44
|
+
// Advance the window.
|
|
45
|
+
const shift = index - this.top;
|
|
46
|
+
this.bitmask = (this.bitmask << shift) | 1n;
|
|
47
|
+
this.top = index;
|
|
48
|
+
} else {
|
|
49
|
+
const diff = this.top - index;
|
|
50
|
+
const bit = 1n << diff;
|
|
51
|
+
this.bitmask |= bit;
|
|
52
|
+
}
|
|
53
|
+
// Keep bitmask bounded to windowSize bits.
|
|
54
|
+
const mask = (1n << this.windowSize) - 1n;
|
|
55
|
+
this.bitmask &= mask;
|
|
56
|
+
}
|
|
57
|
+
}
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { ReplayWindow } from './replay.js';
|
|
2
|
+
|
|
3
|
+
// ---------------------------------------------------------------------------
|
|
4
|
+
// Protection profiles
|
|
5
|
+
// ---------------------------------------------------------------------------
|
|
6
|
+
|
|
7
|
+
export enum ProtectionProfile {
|
|
8
|
+
AES_128_CM_HMAC_SHA1_80 = 0x0001,
|
|
9
|
+
AES_128_CM_HMAC_SHA1_32 = 0x0002,
|
|
10
|
+
AES_128_GCM = 0x0007,
|
|
11
|
+
AES_256_GCM = 0x0008,
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
// ---------------------------------------------------------------------------
|
|
15
|
+
// Keying material supplied by DTLS-SRTP or an external SRTP offer
|
|
16
|
+
// ---------------------------------------------------------------------------
|
|
17
|
+
|
|
18
|
+
export interface SrtpKeyingMaterial {
|
|
19
|
+
/** 16 bytes for AES-128 profiles; 32 bytes for AES-256 */
|
|
20
|
+
masterKey: Buffer;
|
|
21
|
+
/** 14 bytes */
|
|
22
|
+
masterSalt: Buffer;
|
|
23
|
+
profile: ProtectionProfile;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// ---------------------------------------------------------------------------
|
|
27
|
+
// Per-stream cryptographic contexts
|
|
28
|
+
// ---------------------------------------------------------------------------
|
|
29
|
+
|
|
30
|
+
export interface SrtpContext {
|
|
31
|
+
profile: ProtectionProfile;
|
|
32
|
+
/** AES session encryption key */
|
|
33
|
+
sessionEncKey: Buffer;
|
|
34
|
+
/** HMAC-SHA1 authentication key – 20 bytes */
|
|
35
|
+
sessionAuthKey: Buffer;
|
|
36
|
+
/** 14-byte session salt used to compute the IV */
|
|
37
|
+
sessionSaltKey: Buffer;
|
|
38
|
+
/** Full 48-bit packet index: (ROC << 16) | SEQ */
|
|
39
|
+
index: bigint;
|
|
40
|
+
/** Roll-Over Counter */
|
|
41
|
+
rolloverCounter: number;
|
|
42
|
+
/** Last observed RTP sequence number */
|
|
43
|
+
lastSeq: number;
|
|
44
|
+
replayWindow: ReplayWindow;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export interface SrtcpContext {
|
|
48
|
+
profile: ProtectionProfile;
|
|
49
|
+
sessionEncKey: Buffer;
|
|
50
|
+
sessionAuthKey: Buffer;
|
|
51
|
+
sessionSaltKey: Buffer;
|
|
52
|
+
/** 31-bit SRTCP packet index (starts at 1 on first protect call) */
|
|
53
|
+
index: number;
|
|
54
|
+
replayWindow: ReplayWindow;
|
|
55
|
+
}
|