Skip to content

Commit 03fe080

Browse files
committed
buffer: add mask() and unmask()
Add buffer.mask(source, mask, output[, offset[, length]]) and buffer.unmask(buffer, mask), which XOR data with a repeating 4-byte key. This is the masking that WebSocket clients apply to every frame they send (RFC 6455, Section 5.3), and that servers undo on every frame they receive. Userland WebSocket implementations do this either with a byte-by-byte JS loop (undici, and ws without optional dependencies) or with the bufferutil native addon. bufferutil is installed for only about 3% of ws downloads and has no linux-arm64 prebuild, so almost all users run the JS loop. The signature matches bufferutil, so existing users can switch with a feature check, as ws did for buffer.isUtf8(). The implementation uses a V8 fast API call and processes 8-byte words, which the compiler vectorizes. It handles overlapping source and output views, and treats every view type as raw bytes. It is about 20x faster than the JS loop at 1 KiB and 40x faster at 64 KiB, and faster than bufferutil at every size from 32 bytes up. Signed-off-by: James M Snell <jasnell@gmail.com>
1 parent 14e8f5b commit 03fe080

7 files changed

Lines changed: 560 additions & 0 deletions

File tree

‎benchmark/buffers/buffer-mask.js‎

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
'use strict';
2+
3+
const common = require('../common.js');
4+
const { mask, unmask } = require('node:buffer');
5+
6+
const bench = common.createBenchmark(main, {
7+
type: ['mask', 'unmask'],
8+
// 'buffer' uses buffer.mask() / buffer.unmask(). 'js' is the byte-by-byte
9+
// loop that userland WebSocket implementations fall back to without a
10+
// native addon, for comparison.
11+
impl: ['buffer', 'js'],
12+
len: [4, 16, 125, 1024, 16384, 65536],
13+
n: [1e6],
14+
});
15+
16+
function jsMask(source, key, output, offset, length) {
17+
for (let i = 0; i < length; i++) {
18+
output[offset + i] = source[i] ^ key[i & 3];
19+
}
20+
}
21+
22+
function jsUnmask(buffer, key) {
23+
for (let i = 0; i < buffer.length; i++) {
24+
buffer[i] ^= key[i & 3];
25+
}
26+
}
27+
28+
function main({ n, type, impl, len }) {
29+
const key = Buffer.from([0x12, 0x34, 0x56, 0x78]);
30+
const source = Buffer.alloc(len, 'abcdefg');
31+
// Leave room for a WebSocket frame header before the payload.
32+
const output = Buffer.alloc(len + 14);
33+
34+
switch (type) {
35+
case 'mask': {
36+
const fn = impl === 'buffer' ? mask : jsMask;
37+
bench.start();
38+
for (let i = 0; i < n; i++) {
39+
fn(source, key, output, 14, len);
40+
}
41+
bench.end(n);
42+
break;
43+
}
44+
case 'unmask': {
45+
const fn = impl === 'buffer' ? unmask : jsUnmask;
46+
bench.start();
47+
for (let i = 0; i < n; i++) {
48+
fn(source, key);
49+
}
50+
bench.end(n);
51+
break;
52+
}
53+
default:
54+
throw new Error(`Unexpected type: ${type}`);
55+
}
56+
}

‎doc/api/buffer.md‎

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5469,6 +5469,72 @@ added: v3.0.0
54695469

54705470
An alias for [`buffer.constants.MAX_STRING_LENGTH`][].
54715471

5472+
### `buffer.mask(source, mask, output[, offset[, length]])`
5473+
5474+
<!-- YAML
5475+
added: REPLACEME
5476+
-->
5477+
5478+
* `source` {Buffer|TypedArray|DataView} The data to mask.
5479+
* `mask` {Buffer|TypedArray|DataView} The 4-byte masking key.
5480+
* `output` {Buffer|TypedArray|DataView} Where to write the masked data.
5481+
* `offset` {integer} Byte offset in `output` at which to start writing.
5482+
**Default:** `0`.
5483+
* `length` {integer} Number of bytes of `source` to mask.
5484+
**Default:** `source.byteLength`.
5485+
5486+
XORs the first `length` bytes of `source` with `mask`, repeated, and writes the
5487+
result to `output` starting at `offset`: byte `i` of `source` is XORed with
5488+
byte `i % 4` of `mask`. This is the masking operation that WebSocket clients
5489+
apply to every frame payload they send ([RFC 6455, Section 5.3][]), and that
5490+
servers undo on every frame they receive. Applying it twice with the same
5491+
`mask` restores the original data. `source` is not modified, unless it shares
5492+
memory with `output`.
5493+
5494+
All arguments are treated as raw bytes, whatever the view type. `source` and
5495+
`output` may be the same view or overlapping views over the same memory; the
5496+
result is the same as if `source` had been copied first.
5497+
5498+
An error is thrown if `mask` is not exactly 4 bytes long, if `length` is
5499+
greater than `source.byteLength`, or if `offset + length` is greater than
5500+
`output.byteLength`. Nothing is written in that case.
5501+
5502+
```mjs
5503+
import { Buffer, mask, unmask } from 'node:buffer';
5504+
5505+
const key = Buffer.from([0x37, 0xfa, 0x21, 0x3d]);
5506+
const payload = Buffer.from('Hello');
5507+
5508+
// Write a masked copy of the payload after a 6-byte frame header.
5509+
const frame = Buffer.alloc(6 + payload.length);
5510+
mask(payload, key, frame, 6);
5511+
console.log(frame.subarray(6));
5512+
// Prints: <Buffer 7f 9f 4d 51 58>
5513+
5514+
const received = frame.subarray(6);
5515+
unmask(received, key);
5516+
console.log(received.toString());
5517+
// Prints: Hello
5518+
```
5519+
5520+
```cjs
5521+
const { Buffer, mask, unmask } = require('node:buffer');
5522+
5523+
const key = Buffer.from([0x37, 0xfa, 0x21, 0x3d]);
5524+
const payload = Buffer.from('Hello');
5525+
5526+
// Write a masked copy of the payload after a 6-byte frame header.
5527+
const frame = Buffer.alloc(6 + payload.length);
5528+
mask(payload, key, frame, 6);
5529+
console.log(frame.subarray(6));
5530+
// Prints: <Buffer 7f 9f 4d 51 58>
5531+
5532+
const received = frame.subarray(6);
5533+
unmask(received, key);
5534+
console.log(received.toString());
5535+
// Prints: Hello
5536+
```
5537+
54725538
### `buffer.resolveObjectURL(id)`
54735539

54745540
<!-- YAML
@@ -5534,6 +5600,23 @@ console.log(newBuf.toString('ascii'));
55345600
Because the Euro (`€`) sign is not representable in US-ASCII, it is replaced
55355601
with `?` in the transcoded `Buffer`.
55365602

5603+
### `buffer.unmask(buffer, mask)`
5604+
5605+
<!-- YAML
5606+
added: REPLACEME
5607+
-->
5608+
5609+
* `buffer` {Buffer|TypedArray|DataView} The data to unmask, in place.
5610+
* `mask` {Buffer|TypedArray|DataView} The 4-byte masking key.
5611+
5612+
XORs every byte of `buffer` with `mask`, repeated, in place: byte `i` is XORed
5613+
with byte `i % 4` of `mask`. This is equivalent to
5614+
`buffer.mask(buffer, mask, buffer)`, and is typically used to unmask a received
5615+
WebSocket frame payload ([RFC 6455, Section 5.3][]). See [`buffer.mask()`][]
5616+
for an example.
5617+
5618+
An error is thrown if `mask` is not exactly 4 bytes long.
5619+
55375620
### Buffer constants
55385621

55395622
<!-- YAML
@@ -5772,6 +5855,7 @@ or after startup, if the alignment has to hold at run time.
57725855
[Base64]: https://en.wikipedia.org/wiki/Base64
57735856
[ISO-8859-1]: https://en.wikipedia.org/wiki/ISO-8859-1
57745857
[RFC 4648, Section 5]: https://tools.ietf.org/html/rfc4648#section-5
5858+
[RFC 6455, Section 5.3]: https://datatracker.ietf.org/doc/html/rfc6455#section-5.3
57755859
[UTF-16]: https://en.wikipedia.org/wiki/UTF-16
57765860
[UTF-8]: https://en.wikipedia.org/wiki/UTF-8
57775861
[WHATWG Encoding Standard]: https://encoding.spec.whatwg.org/
@@ -5815,6 +5899,7 @@ or after startup, if the alignment has to hold at run time.
58155899
[`buffer.constants.MAX_LENGTH`]: #bufferconstantsmax_length
58165900
[`buffer.constants.MAX_STRING_LENGTH`]: #bufferconstantsmax_string_length
58175901
[`buffer.kMaxLength`]: #bufferkmaxlength
5902+
[`buffer.mask()`]: #buffermasksource-mask-output-offset-length
58185903
[`util.inspect()`]: util.md#utilinspectobject-options
58195904
[`v8.startupSnapshot.setDeserializeMainFunction()`]: v8.md#v8startupsnapshotsetdeserializemainfunctioncallback-data
58205905
[`v8::Uint8Array::kMaxLength`]: https://v8.github.io/api/head/classv8_1_1Uint8Array.html#a7677e3d0c9c92e4d40bef7212f5980c6

‎lib/buffer.js‎

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,7 @@ const {
6363
fill: bindingFill,
6464
isAscii: bindingIsAscii,
6565
isUtf8: bindingIsUtf8,
66+
mask: bindingMask,
6667
stringLengthUtf8: bindingStringLengthUtf8,
6768
indexOfBuffer,
6869
indexOfNumber,
@@ -1498,6 +1499,56 @@ function isAscii(input) {
14981499
throw new ERR_INVALID_ARG_TYPE('input', ['ArrayBuffer', 'Buffer', 'TypedArray'], input);
14991500
}
15001501

1502+
// Packs a 4-byte mask into a uint32, with byte k in bits 8k..8k+7.
1503+
function maskKeyToUint32(key) {
1504+
if (!isArrayBufferView(key)) {
1505+
throw new ERR_INVALID_ARG_TYPE('mask', ['Buffer', 'TypedArray', 'DataView'], key);
1506+
}
1507+
if (key.byteLength !== 4) {
1508+
throw new ERR_INVALID_ARG_VALUE('mask', key, 'must be 4 bytes long');
1509+
}
1510+
if (!isUint8Array(key)) {
1511+
key = new Uint8Array(key.buffer, key.byteOffset, 4);
1512+
}
1513+
return (key[0] | (key[1] << 8) | (key[2] << 16) | (key[3] << 24)) >>> 0;
1514+
}
1515+
1516+
function mask(source, key, output, offset, length) {
1517+
if (!isArrayBufferView(source)) {
1518+
throw new ERR_INVALID_ARG_TYPE('source', ['Buffer', 'TypedArray', 'DataView'], source);
1519+
}
1520+
const maskValue = maskKeyToUint32(key);
1521+
if (!isArrayBufferView(output)) {
1522+
throw new ERR_INVALID_ARG_TYPE('output', ['Buffer', 'TypedArray', 'DataView'], output);
1523+
}
1524+
const outputLength = output.byteLength;
1525+
if (offset === undefined) {
1526+
offset = 0;
1527+
} else {
1528+
validateInteger(offset, 'offset', 0, outputLength);
1529+
}
1530+
if (length === undefined) {
1531+
length = source.byteLength;
1532+
} else {
1533+
validateInteger(length, 'length', 0, source.byteLength);
1534+
}
1535+
if (length > outputLength - offset) {
1536+
throw new ERR_OUT_OF_RANGE('length', `<= ${outputLength - offset}`, length);
1537+
}
1538+
if (length === 0) return;
1539+
bindingMask(source, output, offset, length, maskValue);
1540+
}
1541+
1542+
function unmask(buffer, key) {
1543+
if (!isArrayBufferView(buffer)) {
1544+
throw new ERR_INVALID_ARG_TYPE('buffer', ['Buffer', 'TypedArray', 'DataView'], buffer);
1545+
}
1546+
const maskValue = maskKeyToUint32(key);
1547+
const length = buffer.byteLength;
1548+
if (length === 0) return;
1549+
bindingMask(buffer, buffer, 0, length, maskValue);
1550+
}
1551+
15011552
function stringLength(input, encoding = 'utf8') {
15021553
if (!isTypedArray(input) && !isAnyArrayBuffer(input)) {
15031554
throw new ERR_INVALID_ARG_TYPE('input', ['ArrayBuffer', 'Buffer', 'TypedArray'], input);
@@ -1529,6 +1580,8 @@ module.exports = {
15291580
transcode,
15301581
isUtf8,
15311582
isAscii,
1583+
mask,
1584+
unmask,
15321585

15331586
// Legacy
15341587
kMaxLength,

‎src/node_buffer.cc‎

Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1414,6 +1414,116 @@ static bool FastIsAscii(Local<Value> receiver,
14141414

14151415
static CFunction fast_is_ascii(CFunction::Make(FastIsAscii));
14161416

1417+
// Copies `length` bytes from `source` to `destination`, XORing byte i with
1418+
// byte (i % 4) of `mask`, as done for WebSocket frame payloads (RFC 6455,
1419+
// Section 5.3). The mask bytes are packed little-endian into a uint32, so mask
1420+
// byte k is (mask >> (8 * k)) & 0xff regardless of platform endianness.
1421+
// `source` and `destination` may be the same memory or otherwise overlap.
1422+
static void MaskImpl(const uint8_t* source,
1423+
uint8_t* destination,
1424+
size_t length,
1425+
uint32_t mask) {
1426+
uint8_t pattern[8];
1427+
for (size_t i = 0; i < 8; i++) {
1428+
pattern[i] = static_cast<uint8_t>(mask >> (8 * (i & 3)));
1429+
}
1430+
uint64_t pattern64;
1431+
memcpy(&pattern64, pattern, sizeof(pattern64));
1432+
1433+
// A forward pass is safe when the destination starts at or before the
1434+
// source: every source chunk is read before any overlapping byte is
1435+
// written. Otherwise, take a copy of the source first.
1436+
std::unique_ptr<uint8_t[]> copy;
1437+
if (destination > source && destination < source + length) [[unlikely]] {
1438+
copy.reset(new uint8_t[length]);
1439+
memcpy(copy.get(), source, length);
1440+
source = copy.get();
1441+
}
1442+
1443+
// Chunks start at multiples of 8, so the mask phase of every chunk is 0.
1444+
// memcpy() is used for unaligned loads and stores, and lets the compiler
1445+
// vectorize the loop.
1446+
size_t i = 0;
1447+
for (; i + 32 <= length; i += 32) {
1448+
uint64_t a, b, c, d;
1449+
memcpy(&a, source + i, 8);
1450+
memcpy(&b, source + i + 8, 8);
1451+
memcpy(&c, source + i + 16, 8);
1452+
memcpy(&d, source + i + 24, 8);
1453+
a ^= pattern64;
1454+
b ^= pattern64;
1455+
c ^= pattern64;
1456+
d ^= pattern64;
1457+
memcpy(destination + i, &a, 8);
1458+
memcpy(destination + i + 8, &b, 8);
1459+
memcpy(destination + i + 16, &c, 8);
1460+
memcpy(destination + i + 24, &d, 8);
1461+
}
1462+
for (; i + 8 <= length; i += 8) {
1463+
uint64_t v;
1464+
memcpy(&v, source + i, sizeof(v));
1465+
v ^= pattern64;
1466+
memcpy(destination + i, &v, sizeof(v));
1467+
}
1468+
for (; i < length; i++) destination[i] = source[i] ^ pattern[i & 3];
1469+
}
1470+
1471+
// Arguments are validated in JS: source and destination are ArrayBufferViews,
1472+
// offset + length <= destination.byteLength and length <= source.byteLength.
1473+
static void MaskArgs(Local<Value> source_obj,
1474+
Local<Value> destination_obj,
1475+
size_t offset,
1476+
size_t length,
1477+
uint32_t mask) {
1478+
if (length == 0) return;
1479+
SPREAD_BUFFER_ARG(destination_obj, destination);
1480+
CHECK_LE(offset, destination_length);
1481+
CHECK_LE(length, destination_length - offset);
1482+
uint8_t* dest = reinterpret_cast<uint8_t*>(destination_data) + offset;
1483+
if (source_obj == destination_obj) {
1484+
MaskImpl(
1485+
reinterpret_cast<const uint8_t*>(destination_data), dest, length, mask);
1486+
return;
1487+
}
1488+
SPREAD_BUFFER_ARG(source_obj, source);
1489+
CHECK_LE(length, source_length);
1490+
MaskImpl(reinterpret_cast<const uint8_t*>(source_data), dest, length, mask);
1491+
}
1492+
1493+
// mask(source, destination, offset, length, mask)
1494+
static void Mask(const FunctionCallbackInfo<Value>& args) {
1495+
CHECK_EQ(args.Length(), 5);
1496+
CHECK(args[2]->IsNumber());
1497+
CHECK(args[3]->IsNumber());
1498+
CHECK(args[4]->IsUint32());
1499+
// Offsets and lengths can exceed uint32 for buffers larger than 4 GiB, so
1500+
// they are passed as doubles (exact for integers < 2^53).
1501+
MaskArgs(args[0],
1502+
args[1],
1503+
static_cast<size_t>(args[2].As<Number>()->Value()),
1504+
static_cast<size_t>(args[3].As<Number>()->Value()),
1505+
args[4].As<Uint32>()->Value());
1506+
}
1507+
1508+
static void FastMask(Local<Value> receiver,
1509+
Local<Value> source_obj,
1510+
Local<Value> destination_obj,
1511+
double offset,
1512+
double length,
1513+
uint32_t mask,
1514+
// NOLINTNEXTLINE(runtime/references)
1515+
FastApiCallbackOptions& options) {
1516+
TRACK_V8_FAST_API_CALL("buffer.mask");
1517+
HandleScope scope(options.isolate);
1518+
MaskArgs(source_obj,
1519+
destination_obj,
1520+
static_cast<size_t>(offset),
1521+
static_cast<size_t>(length),
1522+
mask);
1523+
}
1524+
1525+
static CFunction fast_mask(CFunction::Make(FastMask));
1526+
14171527
// Number of UTF-16 code units produced by decoding [p, end) as UTF-8 with
14181528
// WHATWG "maximal subpart" U+FFFD replacement, matching the fallback that
14191529
// StringBytes::Encode takes for invalid input (v8::String::NewFromUtf8).
@@ -1928,6 +2038,7 @@ void Initialize(Local<Object> target,
19282038
SetFastMethodNoSideEffect(context, target, "isUtf8", IsUtf8, &fast_is_utf8);
19292039
SetFastMethodNoSideEffect(
19302040
context, target, "isAscii", IsAscii, &fast_is_ascii);
2041+
SetFastMethod(context, target, "mask", Mask, &fast_mask);
19312042
SetFastMethodNoSideEffect(context,
19322043
target,
19332044
"stringLengthUtf8",
@@ -2008,6 +2119,8 @@ void RegisterExternalReferences(ExternalReferenceRegistry* registry) {
20082119
registry->Register(fast_is_utf8);
20092120
registry->Register(IsAscii);
20102121
registry->Register(fast_is_ascii);
2122+
registry->Register(Mask);
2123+
registry->Register(fast_mask);
20112124
registry->Register(StringLengthUtf8);
20122125
registry->Register(fast_string_length_utf8);
20132126

0 commit comments

Comments
 (0)