Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 56 additions & 0 deletions benchmark/buffers/buffer-mask.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
'use strict';

const common = require('../common.js');
const { mask, unmask } = require('node:buffer');

const bench = common.createBenchmark(main, {
type: ['mask', 'unmask'],
// 'buffer' uses buffer.mask() / buffer.unmask(). 'js' is the byte-by-byte
// loop that userland WebSocket implementations fall back to without a
// native addon, for comparison.
impl: ['buffer', 'js'],
len: [4, 16, 125, 1024, 16384, 65536],
n: [1e6],
});

function jsMask(source, key, output, offset, length) {
for (let i = 0; i < length; i++) {
output[offset + i] = source[i] ^ key[i & 3];
}
}

function jsUnmask(buffer, key) {
for (let i = 0; i < buffer.length; i++) {
buffer[i] ^= key[i & 3];
}
}

function main({ n, type, impl, len }) {
const key = Buffer.from([0x12, 0x34, 0x56, 0x78]);
const source = Buffer.alloc(len, 'abcdefg');
// Leave room for a WebSocket frame header before the payload.
const output = Buffer.alloc(len + 14);

switch (type) {
case 'mask': {
const fn = impl === 'buffer' ? mask : jsMask;
bench.start();
for (let i = 0; i < n; i++) {
fn(source, key, output, 14, len);
}
bench.end(n);
break;
}
case 'unmask': {
const fn = impl === 'buffer' ? unmask : jsUnmask;
bench.start();
for (let i = 0; i < n; i++) {
fn(source, key);
}
bench.end(n);
break;
}
default:
throw new Error(`Unexpected type: ${type}`);
}
}
88 changes: 88 additions & 0 deletions doc/api/buffer.md
Original file line number Diff line number Diff line change
Expand Up @@ -5469,6 +5469,74 @@ added: v3.0.0

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

### `buffer.mask(source, mask, output[, offset[, length]])`

<!-- YAML
added: REPLACEME
-->

* `source` {Buffer|TypedArray|DataView} The data to mask.
* `mask` {Buffer|TypedArray|DataView} The 4-byte masking key.
* `output` {Buffer|TypedArray|DataView} Where to write the masked data.
* `offset` {integer} Byte offset in `output` at which to start writing.
**Default:** `0`.
* `length` {integer} Number of bytes of `source` to mask.
**Default:** `source.byteLength`.

XORs the first `length` bytes of `source` with `mask`, repeated, and writes the
result to `output` starting at `offset`: byte `i` of `source` is XORed with
byte `i % 4` of `mask`. This is the masking operation that WebSocket clients
apply to every frame payload they send ([RFC 6455, Section 5.3][]), and that
servers undo on every frame they receive. Applying it twice with the same
`mask` restores the original data. `source` is not modified, unless it shares
memory with `output`.

All arguments are treated as raw bytes, whatever the view type. `source` and
`output` may be the same view or overlapping views over the same memory; the
result is the same as if `source` had been copied first.

An error is thrown if `mask` is not exactly 4 bytes long, if `length` is
greater than `source.byteLength`, if `offset + length` is greater than
`output.byteLength`, or if `output` is backed by an immutable `ArrayBuffer`.
Nothing is written in that case. `source` and `mask` may be backed by an
immutable `ArrayBuffer`.

```mjs
import { Buffer, mask, unmask } from 'node:buffer';

const key = Buffer.from([0x37, 0xfa, 0x21, 0x3d]);
const payload = Buffer.from('Hello');

// Write a masked copy of the payload after a 6-byte frame header.
const frame = Buffer.alloc(6 + payload.length);
mask(payload, key, frame, 6);
console.log(frame.subarray(6));
// Prints: <Buffer 7f 9f 4d 51 58>

const received = frame.subarray(6);
unmask(received, key);
console.log(received.toString());
// Prints: Hello
```

```cjs
const { Buffer, mask, unmask } = require('node:buffer');

const key = Buffer.from([0x37, 0xfa, 0x21, 0x3d]);
const payload = Buffer.from('Hello');

// Write a masked copy of the payload after a 6-byte frame header.
const frame = Buffer.alloc(6 + payload.length);
mask(payload, key, frame, 6);
console.log(frame.subarray(6));
// Prints: <Buffer 7f 9f 4d 51 58>

const received = frame.subarray(6);
unmask(received, key);
console.log(received.toString());
// Prints: Hello
```

### `buffer.resolveObjectURL(id)`

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

### `buffer.unmask(buffer, mask)`

<!-- YAML
added: REPLACEME
-->

* `buffer` {Buffer|TypedArray|DataView} The data to unmask, in place.
* `mask` {Buffer|TypedArray|DataView} The 4-byte masking key.

XORs every byte of `buffer` with `mask`, repeated, in place: byte `i` is XORed
with byte `i % 4` of `mask`. This is equivalent to
`buffer.mask(buffer, mask, buffer)`, and is typically used to unmask a received
WebSocket frame payload ([RFC 6455, Section 5.3][]). See [`buffer.mask()`][]
for an example.

An error is thrown, and nothing is written, if `mask` is not exactly 4 bytes
long or if `buffer` is backed by an immutable `ArrayBuffer`.

### Buffer constants

<!-- YAML
Expand Down Expand Up @@ -5772,6 +5858,7 @@ or after startup, if the alignment has to hold at run time.
[Base64]: https://en.wikipedia.org/wiki/Base64
[ISO-8859-1]: https://en.wikipedia.org/wiki/ISO-8859-1
[RFC 4648, Section 5]: https://tools.ietf.org/html/rfc4648#section-5
[RFC 6455, Section 5.3]: https://datatracker.ietf.org/doc/html/rfc6455#section-5.3
[UTF-16]: https://en.wikipedia.org/wiki/UTF-16
[UTF-8]: https://en.wikipedia.org/wiki/UTF-8
[WHATWG Encoding Standard]: https://encoding.spec.whatwg.org/
Expand Down Expand Up @@ -5815,6 +5902,7 @@ or after startup, if the alignment has to hold at run time.
[`buffer.constants.MAX_LENGTH`]: #bufferconstantsmax_length
[`buffer.constants.MAX_STRING_LENGTH`]: #bufferconstantsmax_string_length
[`buffer.kMaxLength`]: #bufferkmaxlength
[`buffer.mask()`]: #buffermasksource-mask-output-offset-length
[`util.inspect()`]: util.md#utilinspectobject-options
[`v8.startupSnapshot.setDeserializeMainFunction()`]: v8.md#v8startupsnapshotsetdeserializemainfunctioncallback-data
[`v8::Uint8Array::kMaxLength`]: https://v8.github.io/api/head/classv8_1_1Uint8Array.html#a7677e3d0c9c92e4d40bef7212f5980c6
Expand Down
76 changes: 76 additions & 0 deletions lib/buffer.js
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ const {
fill: bindingFill,
isAscii: bindingIsAscii,
isUtf8: bindingIsUtf8,
mask: bindingMask,
stringLengthUtf8: bindingStringLengthUtf8,
indexOfBuffer,
indexOfNumber,
Expand Down Expand Up @@ -1498,6 +1499,79 @@ function isAscii(input) {
throw new ERR_INVALID_ARG_TYPE('input', ['ArrayBuffer', 'Buffer', 'TypedArray'], input);
}

/**
* Packs a 4-byte mask into a uint32, with byte k in bits 8k..8k+7.
* @param {ArrayBufferView} key
* @returns {number}
*/
function maskKeyToUint32(key) {
if (!isArrayBufferView(key)) {
throw new ERR_INVALID_ARG_TYPE('mask', ['Buffer', 'TypedArray', 'DataView'], key);
}
if (key.byteLength !== 4) {
throw new ERR_INVALID_ARG_VALUE('mask', key, 'must be 4 bytes long');
}
const bytes = isUint8Array(key) ?
/** @type {Uint8Array} */ (key) :
new Uint8Array(key.buffer, key.byteOffset, 4);
return (bytes[0] | (bytes[1] << 8) | (bytes[2] << 16) | (bytes[3] << 24)) >>> 0;
}

/**
* XORs `length` bytes of `source` with the repeating 4-byte `key` and writes
* the result to `output` at `offset` (RFC 6455, Section 5.3).
* @param {ArrayBufferView} source
* @param {ArrayBufferView} key
* @param {ArrayBufferView} output
* @param {number} [offset]
* @param {number} [length]
* @returns {void}
*/
function mask(source, key, output, offset, length) {
if (!isArrayBufferView(source)) {
throw new ERR_INVALID_ARG_TYPE('source', ['Buffer', 'TypedArray', 'DataView'], source);
}
const maskValue = maskKeyToUint32(key);
if (!isArrayBufferView(output)) {
throw new ERR_INVALID_ARG_TYPE('output', ['Buffer', 'TypedArray', 'DataView'], output);
}
const outputLength = output.byteLength;
if (offset === undefined) {
offset = 0;
} else {
validateInteger(offset, 'offset', 0, outputLength);
}
if (length === undefined) {
length = source.byteLength;
} else {
validateInteger(length, 'length', 0, source.byteLength);
}
if (length > outputLength - offset) {
throw new ERR_OUT_OF_RANGE('length', `<= ${outputLength - offset}`, length);
}
if (!bindingMask(source, output, offset, length, maskValue)) {
throw new ERR_INVALID_ARG_VALUE(
'output', output, 'must not be backed by an immutable ArrayBuffer');
}
}

/**
* XORs every byte of `buffer` with the repeating 4-byte `key`, in place.
* @param {ArrayBufferView} buffer
* @param {ArrayBufferView} key
* @returns {void}
*/
function unmask(buffer, key) {
if (!isArrayBufferView(buffer)) {
throw new ERR_INVALID_ARG_TYPE('buffer', ['Buffer', 'TypedArray', 'DataView'], buffer);
}
const maskValue = maskKeyToUint32(key);
if (!bindingMask(buffer, buffer, 0, buffer.byteLength, maskValue)) {
throw new ERR_INVALID_ARG_VALUE(
'buffer', buffer, 'must not be backed by an immutable ArrayBuffer');
}
}

function stringLength(input, encoding = 'utf8') {
if (!isTypedArray(input) && !isAnyArrayBuffer(input)) {
throw new ERR_INVALID_ARG_TYPE('input', ['ArrayBuffer', 'Buffer', 'TypedArray'], input);
Expand Down Expand Up @@ -1529,6 +1603,8 @@ module.exports = {
transcode,
isUtf8,
isAscii,
mask,
unmask,

// Legacy
kMaxLength,
Expand Down
Loading
Loading