Skip to content

Commit

Permalink
test and fix oops (#9)
Browse files Browse the repository at this point in the history
- port tests from `pull-box-stream`
- fix anything that fails tests
  - handle stream chunks > max content length
  - max content length is (2^16 - 1), not (2^16)
  - content length is encoded as uint, not int
  • Loading branch information
ahdinosaur committed Dec 8, 2023
1 parent 24d7318 commit 3a2d6bb
Show file tree
Hide file tree
Showing 9 changed files with 308 additions and 39 deletions.
6 changes: 3 additions & 3 deletions SPEC.md
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,7 @@ We start with a length chunk, seen here in plaintext:

The length is a 16-bits unsigned integer (encoded as big-endian).

(The maximum content length is 2^16 bytes or 65,536 bytes or 65.536 Kb)
(The maximum content length is $`2^{16} - 1`$ bytes or 65,535 bytes or 65.535 Kb)

A length of `0` is not a valid length. (And instead refers to a [End-of-stream chunk](#end-of-stream-chunk))

Expand All @@ -106,9 +106,9 @@ We encrypt and authenticate the length with ChaCha20-Poly1305 into the following

A content chunk is simply the content.

From 0 to 2^16 (65,536) bytes. (Matching the length in the previous chunk.)
From $`0`$ to $`2^{16} - 1`$ (65,535) bytes. (Matching the length in the previous chunk.)

If content is larger than 2^16 (65,536) bytes, split the bytes across multiple chunks.
If content is larger than $`2^{16} - 1`$ (65,535) bytes, split the bytes across multiple chunks.

```txt
Variable length content (plaintext):
Expand Down
1 change: 1 addition & 0 deletions js/pull-secret-channel/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@
"secret-channel": "^1.0.0"
},
"devDependencies": {
"pull-bitflipper": "^0.1.1",
"pull-stream": "^3.7.0"
}
}
22 changes: 19 additions & 3 deletions js/pull-secret-channel/src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ const {
NONCE_SIZE,
LENGTH_OR_END_CIPHERTEXT,
TAG_SIZE,
MAX_CONTENT_LENGTH,
} = require('secret-channel')

module.exports = {
Expand All @@ -25,6 +26,7 @@ module.exports = {
@typedef {(end: End, cb: (end: End, data?: any) => void) => void} Source
@typedef {{
queue: (buf: B4A | null) => void
emit: (event: 'data' | 'end' | 'error', data?: any) => void
}} PullThroughThis
*/

Expand All @@ -41,9 +43,23 @@ function pullEncrypter(key, nonce) {
* @param {B4A} contentPlaintext
*/
function pullEncrypterData(contentPlaintext) {
const [lengthCiphertext, contentCiphertext] = encrypter.next(contentPlaintext)
this.queue(lengthCiphertext)
this.queue(contentCiphertext)
if (contentPlaintext.length === 0) {
return // skip
}

try {
let totalContentPlaintext = contentPlaintext
while (totalContentPlaintext.length > 0) {
const nextContentPlaintext = totalContentPlaintext.subarray(0, MAX_CONTENT_LENGTH)
totalContentPlaintext = totalContentPlaintext.subarray(MAX_CONTENT_LENGTH)

const [lengthCiphertext, contentCiphertext] = encrypter.next(nextContentPlaintext)
this.queue(lengthCiphertext)
this.queue(contentCiphertext)
}
} catch (err) {
this.emit('error', err)
}
},

/**
Expand Down
30 changes: 0 additions & 30 deletions js/pull-secret-channel/test/basics.js

This file was deleted.

265 changes: 265 additions & 0 deletions js/pull-secret-channel/test/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,265 @@
const test = require('node:test')
const assert = require('node:assert')
const pull = require('pull-stream')
const pullBitflipper = require('pull-bitflipper')
const { randomBytes } = require('crypto')
const { pullEncrypter, pullDecrypter, KEY_SIZE, NONCE_SIZE } = require('../')
const { randomInt } = require('node:crypto')

test('encrypt and decrypt: simple', async (t) => {
// generate a random secret, `KEYBYTES` bytes long.
const key = randomBytes(KEY_SIZE)
// generate a random nonce, `NONCE_SIZE` bytes long.
const nonce = randomBytes(NONCE_SIZE)

const plaintext1 = Buffer.from('hello world')

await new Promise((resolve, _reject) => {
pull(
pull.values([plaintext1]),
pullEncrypter(key, nonce),
pullDecrypter(key, nonce),
pull.concat((err, plaintext2) => {
assert.ifError(err)
assert.equal(plaintext2.toString('utf8'), plaintext1.toString('utf8'))
resolve()
}),
)
})
})

test('encrypt and decrypt: random buffers', async (t) => {
const key = randomBytes(KEY_SIZE)
const nonce = randomBytes(NONCE_SIZE)
const inputBuffers = randomBuffers(randomInt(1e2, 1e3), () => randomInt(1, 1e5))

await new Promise((resolve, _reject) => {
pull(
pull.values(inputBuffers),
pullEncrypter(key, nonce),
pullDecrypter(key, nonce),
pull.collect((err, outputBuffers) => {
assert.ifError(err)

const input = Buffer.concat(inputBuffers)
const output = Buffer.concat(outputBuffers)
assert.equal(output.length, input.length)
assert.equal(output.toString('utf8'), input.toString('utf8'))

resolve()
}),
)
})
})

test('detect flipped bits', async (t) => {
const key = randomBytes(KEY_SIZE)
const nonce = randomBytes(NONCE_SIZE)
const inputBuffers = randomBuffers(100, () => 1024)

await new Promise((resolve, _reject) => {
pull(
pull.values(inputBuffers),
pullEncrypter(key, nonce),
pullBitflipper(0.2),
pullDecrypter(key, nonce),
pull.collect((err, outputBuffers) => {
assert.ok(err)
assert.equal(err.message, 'could not verify data')
assert.notEqual(outputBuffers.length, inputBuffers.length)
resolve()
}),
)
})
})

test('protect against reordering', async (t) => {
const key = randomBytes(KEY_SIZE)
const nonce = randomBytes(NONCE_SIZE)
const inputBuffers = randomBuffers(100, () => 1024)

await new Promise((resolve, _reject) => {
pull(
pull.values(inputBuffers),
pullEncrypter(key, nonce),
pull.collect((err, valid) => {
assert.ifError(err)

// randomly switch two blocks
const invalid = valid.slice()
// since every even packet is a header,
// moving those will produce valid messages
// but the counters will be wrong.
const i = randomInt(valid.length)
let j
do j = randomInt(valid.length)
while (j === i)
invalid[i] = valid[j]
invalid[i + 1] = valid[j + 1]
invalid[j] = valid[i]
invalid[j + 1] = valid[i + 1]

pull(
pull.values(invalid),
pullDecrypter(key, nonce),
pull.collect((err, outputBuffers) => {
assert.ok(err)
assert.equal(err.message, 'could not verify data')
assert.notEqual(outputBuffers.length, inputBuffers.length)
resolve()
}),
)
}),
)
})
})

test('detect unexpected hangup', async (t) => {
const key = randomBytes(KEY_SIZE)
const nonce = randomBytes(NONCE_SIZE)

const inputBuffers = [
Buffer.from('I <3 TLS\n'),
Buffer.from('...\n'),
Buffer.from('NOT!!!!!!!!!!!!!!!\n'),
]

await new Promise((resolve, _reject) => {
pull(
pull.values(inputBuffers),
pullEncrypter(key, nonce),
pull.take(4), // header content header content.
pullDecrypter(key, nonce),
pull.collect((err, outputBuffers) => {
assert.ok(err) // expects an error
assert.equal(
err.message,
'pull-secret-channel/decrypter: stream ended before end-of-stream message',
)
assert.equal(outputBuffers.length, 2)
assert.equal(Buffer.concat(outputBuffers).toString('utf8'), 'I <3 TLS\n...\n')
resolve()
}),
)
})
})

test('immediately hangup', async (t) => {
const key = randomBytes(KEY_SIZE)
const nonce = randomBytes(NONCE_SIZE)

await new Promise((resolve, _reject) => {
pull(
pull.values([]),
pullEncrypter(key, nonce),
pullDecrypter(key, nonce),
pull.collect((err, outputBuffers) => {
assert.ifError(err)
assert.deepEqual(outputBuffers, [])
resolve()
}),
)
})
})

test('skip empty buffers', async (t) => {
const key = randomBytes(KEY_SIZE)
const nonce = randomBytes(NONCE_SIZE)

const inputBuffers = [
Buffer.alloc(0),
Buffer.from('hello'),
Buffer.alloc(0),
Buffer.from('world'),
]
let chunks = 0

await new Promise((resolve, _reject) => {
pull(
pull.values(inputBuffers),
pullEncrypter(key, nonce),
pull.through(() => {
chunks++
}),
pullDecrypter(key, nonce),
pull.collect((err, outputBuffers) => {
assert.ifError(err)

const input = Buffer.concat(inputBuffers)
const output = Buffer.concat(outputBuffers)
assert.equal(output.length, input.length)
assert.equal(output.toString('utf8'), input.toString('utf8'))

assert.equal(chunks, 5) // header, content, header, content, end-of-stream

resolve()
}),
)
})
})

function randomBuffers(bufferCount, getBufferLength) {
const buffers = []
for (let i = 0; i < bufferCount; i++) {
buffers.push(randomBytes(getBufferLength()))
}
return buffers
}

test('stalled abort: encrypter', async (t) => {
const key = randomBytes(KEY_SIZE)
const nonce = randomBytes(NONCE_SIZE)

const stallErr = new Error('intentional stall')
const read = pull(stall(), pullEncrypter(key, nonce))

let i = 0

await new Promise((resolve, _reject) => {
read(null, function (err, _data) {
assert.equal(err, stallErr)
assert.equal(++i, 1)
})

read(stallErr, function () {
assert.ok(true)
assert.equal(++i, 2)
resolve()
})
})
})

test('stalled abort: decrypter', async (t) => {
const key = randomBytes(KEY_SIZE)
const nonce = randomBytes(NONCE_SIZE)

const stallErr = new Error('intentional stall')
const read = pull(stall(), pullDecrypter(key, nonce))

let i = 0

await new Promise((resolve, _reject) => {
read(null, function (err, _data) {
assert.equal(err, stallErr)
assert.equal(++i, 1)
})

read(stallErr, function () {
assert.ok(true)
assert.equal(++i, 2)
resolve()
})
})
})

function stall() {
var _cb
return function (abort, cb) {
if (abort) {
_cb && _cb(abort)
cb && cb(abort)
} else {
_cb = cb
}
}
}
4 changes: 4 additions & 0 deletions js/secret-channel/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,10 @@ The size of decrypted plaintext is `ciphertext.length - TAG_SIZE`.

18 bytes (`LENGTH_OR_END_PLAINTEXT + TAG_SIZE`)

### `MAX_CONTENT_LENGTH`

$`2^{16} - 1`$

## Example

### Encryption
Expand Down
3 changes: 2 additions & 1 deletion js/secret-channel/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -48,5 +48,6 @@
"b4a": "^1.6.4",
"debug": "^4.3.4",
"sodium-native": "^4.0.4"
}
},
"devDependencies": {}
}
Loading

0 comments on commit 3a2d6bb

Please sign in to comment.