Skip to content

Commit ae2229e

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 3641c36 commit ae2229e

8 files changed

Lines changed: 666 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: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5469,6 +5469,74 @@ 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`, if `offset + length` is greater than
5500+
`output.byteLength`, or if `output` is backed by an immutable `ArrayBuffer`.
5501+
Nothing is written in that case. `source` and `mask` may be backed by an
5502+
immutable `ArrayBuffer`.
5503+
5504+
```mjs
5505+
import { Buffer, mask, unmask } from 'node:buffer';
5506+
5507+
const key = Buffer.from([0x37, 0xfa, 0x21, 0x3d]);
5508+
const payload = Buffer.from('Hello');
5509+
5510+
// Write a masked copy of the payload after a 6-byte frame header.
5511+
const frame = Buffer.alloc(6 + payload.length);
5512+
mask(payload, key, frame, 6);
5513+
console.log(frame.subarray(6));
5514+
// Prints: <Buffer 7f 9f 4d 51 58>
5515+
5516+
const received = frame.subarray(6);
5517+
unmask(received, key);
5518+
console.log(received.toString());
5519+
// Prints: Hello
5520+
```
5521+
5522+
```cjs
5523+
const { Buffer, mask, unmask } = require('node:buffer');
5524+
5525+
const key = Buffer.from([0x37, 0xfa, 0x21, 0x3d]);
5526+
const payload = Buffer.from('Hello');
5527+
5528+
// Write a masked copy of the payload after a 6-byte frame header.
5529+
const frame = Buffer.alloc(6 + payload.length);
5530+
mask(payload, key, frame, 6);
5531+
console.log(frame.subarray(6));
5532+
// Prints: <Buffer 7f 9f 4d 51 58>
5533+
5534+
const received = frame.subarray(6);
5535+
unmask(received, key);
5536+
console.log(received.toString());
5537+
// Prints: Hello
5538+
```
5539+
54725540
### `buffer.resolveObjectURL(id)`
54735541

54745542
<!-- YAML
@@ -5534,6 +5602,24 @@ console.log(newBuf.toString('ascii'));
55345602
Because the Euro (`€`) sign is not representable in US-ASCII, it is replaced
55355603
with `?` in the transcoded `Buffer`.
55365604

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

55395625
<!-- YAML
@@ -5772,6 +5858,7 @@ or after startup, if the alignment has to hold at run time.
57725858
[Base64]: https://en.wikipedia.org/wiki/Base64
57735859
[ISO-8859-1]: https://en.wikipedia.org/wiki/ISO-8859-1
57745860
[RFC 4648, Section 5]: https://tools.ietf.org/html/rfc4648#section-5
5861+
[RFC 6455, Section 5.3]: https://datatracker.ietf.org/doc/html/rfc6455#section-5.3
57755862
[UTF-16]: https://en.wikipedia.org/wiki/UTF-16
57765863
[UTF-8]: https://en.wikipedia.org/wiki/UTF-8
57775864
[WHATWG Encoding Standard]: https://encoding.spec.whatwg.org/
@@ -5815,6 +5902,7 @@ or after startup, if the alignment has to hold at run time.
58155902
[`buffer.constants.MAX_LENGTH`]: #bufferconstantsmax_length
58165903
[`buffer.constants.MAX_STRING_LENGTH`]: #bufferconstantsmax_string_length
58175904
[`buffer.kMaxLength`]: #bufferkmaxlength
5905+
[`buffer.mask()`]: #buffermasksource-mask-output-offset-length
58185906
[`util.inspect()`]: util.md#utilinspectobject-options
58195907
[`v8.startupSnapshot.setDeserializeMainFunction()`]: v8.md#v8startupsnapshotsetdeserializemainfunctioncallback-data
58205908
[`v8::Uint8Array::kMaxLength`]: https://v8.github.io/api/head/classv8_1_1Uint8Array.html#a7677e3d0c9c92e4d40bef7212f5980c6

‎lib/buffer.js‎

Lines changed: 56 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,59 @@ 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 (!bindingMask(source, output, offset, length, maskValue)) {
1539+
throw new ERR_INVALID_ARG_VALUE(
1540+
'output', output, 'must not be backed by an immutable ArrayBuffer');
1541+
}
1542+
}
1543+
1544+
function unmask(buffer, key) {
1545+
if (!isArrayBufferView(buffer)) {
1546+
throw new ERR_INVALID_ARG_TYPE('buffer', ['Buffer', 'TypedArray', 'DataView'], buffer);
1547+
}
1548+
const maskValue = maskKeyToUint32(key);
1549+
if (!bindingMask(buffer, buffer, 0, buffer.byteLength, maskValue)) {
1550+
throw new ERR_INVALID_ARG_VALUE(
1551+
'buffer', buffer, 'must not be backed by an immutable ArrayBuffer');
1552+
}
1553+
}
1554+
15011555
function stringLength(input, encoding = 'utf8') {
15021556
if (!isTypedArray(input) && !isAnyArrayBuffer(input)) {
15031557
throw new ERR_INVALID_ARG_TYPE('input', ['ArrayBuffer', 'Buffer', 'TypedArray'], input);
@@ -1529,6 +1583,8 @@ module.exports = {
15291583
transcode,
15301584
isUtf8,
15311585
isAscii,
1586+
mask,
1587+
unmask,
15321588

15331589
// Legacy
15341590
kMaxLength,

‎src/node_buffer.cc‎

Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1414,6 +1414,126 @@ 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+
// Returns false, without writing anything, if the destination is backed by an
1474+
// immutable ArrayBuffer.
1475+
static bool MaskArgs(Local<Value> source_obj,
1476+
Local<Value> destination_obj,
1477+
size_t offset,
1478+
size_t length,
1479+
uint32_t mask) {
1480+
CHECK(destination_obj->IsArrayBufferView());
1481+
Local<ArrayBufferView> destination = destination_obj.As<ArrayBufferView>();
1482+
Local<ArrayBuffer> destination_ab = destination->Buffer();
1483+
if (destination_ab->IsImmutable()) return false;
1484+
if (length == 0) return true;
1485+
const size_t destination_length = destination->ByteLength();
1486+
CHECK_LE(offset, destination_length);
1487+
CHECK_LE(length, destination_length - offset);
1488+
uint8_t* destination_data =
1489+
static_cast<uint8_t*>(destination_ab->Data()) + destination->ByteOffset();
1490+
CHECK_NOT_NULL(destination_data);
1491+
uint8_t* dest = destination_data + offset;
1492+
if (source_obj == destination_obj) {
1493+
MaskImpl(destination_data, dest, length, mask);
1494+
return true;
1495+
}
1496+
SPREAD_BUFFER_ARG(source_obj, source);
1497+
CHECK_LE(length, source_length);
1498+
MaskImpl(reinterpret_cast<const uint8_t*>(source_data), dest, length, mask);
1499+
return true;
1500+
}
1501+
1502+
// mask(source, destination, offset, length, mask)
1503+
static void Mask(const FunctionCallbackInfo<Value>& args) {
1504+
CHECK_EQ(args.Length(), 5);
1505+
CHECK(args[2]->IsNumber());
1506+
CHECK(args[3]->IsNumber());
1507+
CHECK(args[4]->IsUint32());
1508+
// Offsets and lengths can exceed uint32 for buffers larger than 4 GiB, so
1509+
// they are passed as doubles (exact for integers < 2^53).
1510+
args.GetReturnValue().Set(
1511+
MaskArgs(args[0],
1512+
args[1],
1513+
static_cast<size_t>(args[2].As<Number>()->Value()),
1514+
static_cast<size_t>(args[3].As<Number>()->Value()),
1515+
args[4].As<Uint32>()->Value()));
1516+
}
1517+
1518+
static bool FastMask(Local<Value> receiver,
1519+
Local<Value> source_obj,
1520+
Local<Value> destination_obj,
1521+
double offset,
1522+
double length,
1523+
uint32_t mask,
1524+
// NOLINTNEXTLINE(runtime/references)
1525+
FastApiCallbackOptions& options) {
1526+
TRACK_V8_FAST_API_CALL("buffer.mask");
1527+
HandleScope scope(options.isolate);
1528+
return MaskArgs(source_obj,
1529+
destination_obj,
1530+
static_cast<size_t>(offset),
1531+
static_cast<size_t>(length),
1532+
mask);
1533+
}
1534+
1535+
static CFunction fast_mask(CFunction::Make(FastMask));
1536+
14171537
// Number of UTF-16 code units produced by decoding [p, end) as UTF-8 with
14181538
// WHATWG "maximal subpart" U+FFFD replacement, matching the fallback that
14191539
// StringBytes::Encode takes for invalid input (v8::String::NewFromUtf8).
@@ -1928,6 +2048,7 @@ void Initialize(Local<Object> target,
19282048
SetFastMethodNoSideEffect(context, target, "isUtf8", IsUtf8, &fast_is_utf8);
19292049
SetFastMethodNoSideEffect(
19302050
context, target, "isAscii", IsAscii, &fast_is_ascii);
2051+
SetFastMethod(context, target, "mask", Mask, &fast_mask);
19312052
SetFastMethodNoSideEffect(context,
19322053
target,
19332054
"stringLengthUtf8",
@@ -2008,6 +2129,8 @@ void RegisterExternalReferences(ExternalReferenceRegistry* registry) {
20082129
registry->Register(fast_is_utf8);
20092130
registry->Register(IsAscii);
20102131
registry->Register(fast_is_ascii);
2132+
registry->Register(Mask);
2133+
registry->Register(fast_mask);
20112134
registry->Register(StringLengthUtf8);
20122135
registry->Register(fast_string_length_utf8);
20132136

0 commit comments

Comments
 (0)