diff --git a/README.md b/README.md index 898ce26..c936c60 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,6 @@ # secret-channel -_Work in progress_ - -Streaming authenticated encryption using ChaCha20-Poly1305 ([RFC 8439](https://datatracker.ietf.org/doc/html/rfc8439)) (or other [AEAD constructions](https://libsodium.gitbook.io/doc/secret-key_cryptography/aead)). +Streaming authenticated encryption using ChaCha20-Poly1305 ([RFC 8439](https://datatracker.ietf.org/doc/html/rfc8439)) (or other [AEADs](https://libsodium.gitbook.io/doc/secret-key_cryptography/aead)). `secret-channel` is designed to be easy to implement and provide [security guarantees](#security-guarantees) (if you abide by the [pre-requisites](#pre-requisites)). @@ -12,7 +10,7 @@ Streaming authenticated encryption using ChaCha20-Poly1305 ([RFC 8439](https://d - The channel must be reliable and ordered: i.e. TCP. - Each channel key must be an ephemeral key for a single channel and discarded when the channel ends. - - To get an ephemeral key for a session, you should do a secure key exchange, such as [`secret-handshake`](https://github.com/auditdrivencrypto/secret-handshake). + - To get an ephemeral key for a session, you should do a secure key exchange, such as [Secret Handshake](https://dominictarr.github.io/secret-handshake-paper/shs.pdf). - For a duplex (bi-directional) connection between peers, you should create two secret channels (with separate keys), one in each direction. - A (key, nonce) pair must NEVER be re-used. diff --git a/SPEC.md b/SPEC.md index 024e808..1fc6032 100644 --- a/SPEC.md +++ b/SPEC.md @@ -1,11 +1,25 @@ # "Secret Channel" Specification +Streaming authenticated encryption using ChaCha20-Poly1305 ([RFC 8439](https://datatracker.ietf.org/doc/html/rfc8439)). + ## Pre-requisites - The channel must be reliable and ordered: i.e. TCP. - Each channel key must be an ephemeral key for a single channel and discarded when the channel ends. + - To get an ephemeral key for a session, do a secure key exchange, such as [Noise](https://noiseprotocol.org/noise.html) or [Secret Handshake](https://dominictarr.github.io/secret-handshake-paper/shs.pdf) first. +- For a duplex (bi-directional) connection between peers, create two secret channels (with separate keys), one in each direction. - A (key, nonce) pair must NEVER be re-used. +## Security Guarantees + +`secret-channel` protects the stream from: + +- Stream truncation: avoided by checking for "end-of-stream" as the final chunk. +- Chunk removal: the wrong nonce would be used, producing an AEAD decryption error. +- Chunk reordering: the wrong nonce would be used, producing an AEAD decryption error. +- Chunk duplication: the wrong nonce would be used, producing an AEAD decryption error. +- Chunk modification: this is what an AEAD is designed to detect. + ## Stream Data is sent over the channel in chunks. @@ -13,7 +27,7 @@ Data is sent over the channel in chunks. - Either ([Length](#length-chunk), [Content](#content-chunk)) chunk pairs, - or a single ([End-of-stream](#end-of-stream-chunk)) chunk. -Each chunk MUST be encrypted with a unique nonce. +Each chunk MUST be encrypted with a unique [nonce](#nonces). ```txt +---------------------+-------------------------------------------------------+ @@ -25,27 +39,27 @@ Each chunk MUST be encrypted with a unique nonce. ### Nonces -To ensure unique nonces over the channel session, we will use a simple counter. +ChaCha20-Poly1305 requires a 12-byte (96-bit) nonce. -The counter starts at 0 and increments by 1 with every chunk. +We must ensure both random and unique nonces over the channel session. -(This is okay because 1) we will never re-use a key, and 2) a 256-bit key protects against [batch/multi-target attacks](https://blog.cr.yp.to/20151120-batchattacks.html).) +We start with a preset (random) 12-byte (96-bit) nonce, provided when creating the stream. -Since the ChaCha20-Poly1305 nonce is 12 bytes (96-bits), we will use a 64-bit unsigned integer as our counter sequence number. +After each chunk, we increment the 12-byte (96-bit) nonce as a little-endian unsigned integer. -The 64-bit counter sequence number is encoded to the 96-bit nonce as follows: +To increment a 12-byte little-endian unsigned integer, see [libsodium `increment`](https://doc.libsodium.org/helpers#incrementing-large-numbers), or the following JavaScript code: -```txt -nonce: -+-----------------+-------------+ -| sequence number | padding | -+-----------------+-------------+ -| 8B (u64_le) | 4B (0x0000) | -+-----------------+-------------+ +```js +function increment(buf) { + let c = 1 + for (let i = 0; i < buf.length; i++) { + c += buf[i] + buf[i] = c + c >>= 8 + } +} ``` -If the counter sequence number overflows, the channel MUST end. (This is not expected to happen.) - ### Chunks #### Length chunk @@ -57,11 +71,11 @@ We start with a length chunk, seen here in plaintext: +---------------+ | length | +---------------+ -| 2B (u16_le) | +| 2B (u16_be) | +---------------+ ``` -The length is a 16-bits unsigned integer (encoded as little-endian). +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) @@ -130,6 +144,62 @@ Then encrypted and authenticated with ChaCha20-Poly1305. +-----------------+------------+ ``` +## Comparisons + +### Scuttlebutt's Box Stream + +Secret Channel is meant to be a successor to Scuttlebutt's [Box Stream](https://ssbc.github.io/scuttlebutt-protocol-guide/#box-stream). + +A few similarities: + +- Box Stream and Secret Channel both use a preset (random) nonce to start and then increment after each chunk. +- Box Stream and Secret Channel both have length and content chunks. + +A few differences: + +- Box Stream increments the nonce as a big-endian unsigned integer. + - Secret Channel increments the nonce as little-endian, to be compatible with `libsodium.increment` and more favorable to most CPU architectures. +- Box Stream uses `libsodium.crypto_secretbox_easy` and `libsodium.crypto_secretbox_open_easy`, which uses XSalsa20-Poly1305. + - Secret Channel uses ChaCha20-Poly1305 (the successor to Salsa20-Poly1305) as an AEAD directly. +- Box Stream appends the authentication tag of the encrypted content into the plaintext of the length chunk. + +### Libsodium's secretstream + +Libsodium's secretstream is designed to be extra safe and resistant to developer misuse. + +Libsodium's secretstream has more features not included in Secret Channel: + +- secretstream is a chunked message stream, where each message has a tag: `TAG_MESSAGE`, `TAG_FINAL`, `TAG_PUSH`, and `TAG_REKEY` +- secretstream uses HChaCha20 to derive a subkey and takes the last 64 bits of the nonce as the nonce for encryption. + - This nonce is stored/sent as a header from the encrypter to the decrypter at the beginning of the stream. +- secretstream uses a 32-bit counter starting at 1 that is prepended to the 64-bit nonce + - After every message, the 64-bit nonce becomes the nonce XOR the first 64 bits of the Poly1305 tag. + - If the counter is 0, the stream will automatically re-key +- secretstream gives no guidance on how to handle variable length messages. + - Libsodium provides functions `sodium_pad` and `sodium_unpad` to pad messages to fixed lengths. + +Both secretstream and Secret Channel use ChaCha20-Poly1305 for encryption. + +secretstream has affordances that Secret Channel doesn't need: + +- By sending the initial nonce as a header, secretstream doesn't require the encrypter and decrypter to have a shared initial nonce. + - Secret Channel is designed for a use with Secret Handshake where we already have a way to generate a shared initial nonce. +- By using a 64-bit random nonce with a 32-bit counter, secretstream is more safe to re-use keys??? + - Secret Channel explicitly disallows any key re-use. +- By XOR'ing the nonce with the previous Poly1305 tag, secretstream is more safe ...??? + - This also prevents random-access decryption. + +### STREAM + ChaCha20-Poly1305 + +STREAM is designed to avoid nonce-reuse in practical settings where keys may be re-used. + +- STREAM is a pattern of using any AEAD as a stream of messages. +- STREAM encodes the last message with a tag in the AD. +- STREAM creates each nonce from a random 64-bit prefix and a 32-bit counter. + - The likelihood of a collision, even when re-using keys, is considered safe enough. + - Secret Channel avoids this problem by explicitly disallowing any key re-use. +- STREAM gives no guidance on how to handle variable length messages. + ## References - [STREAM: "Online Authenticated-Encryption and its Nonce-Reuse Misuse-Resistance"](https://eprint.iacr.org/2015/189.pdf). @@ -138,3 +208,4 @@ Then encrypted and authenticated with ChaCha20-Poly1305. - [shadowsocks SIP022 AEAD-2022](https://github.com/shadowsocks/shadowsocks-org/blob/main/docs/doc/sip022.md) - [libsodium: Encrypting a set of related messages](https://libsodium.gitbook.io/doc/secret-key_cryptography/encrypted-messages) - [ChaCha20-Poly1305 Cipher Suites for Transport Layer Security (TLS)](https://www.rfc-editor.org/rfc/rfc7905) +- [The Security of ChaCha20-Poly1305 in the Multi-user Setting](https://eprint.iacr.org/2023/085.pdf) diff --git a/js/pull-secret-channel/examples/roundtrip.js b/js/pull-secret-channel/examples/roundtrip.js index ab09390..6ba261b 100644 --- a/js/pull-secret-channel/examples/roundtrip.js +++ b/js/pull-secret-channel/examples/roundtrip.js @@ -1,9 +1,11 @@ const { randomBytes } = require('crypto') const pull = require('pull-stream') -const { KEY_SIZE, pullEncrypter, pullDecrypter } = require('../') +const { pullEncrypter, pullDecrypter, KEY_SIZE, NONCE_SIZE } = require('../') // generate a random secret, `KEY_SIZE` 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') @@ -11,7 +13,7 @@ pull( pull.values([plaintext1]), // encrypt every byte - pullEncrypter(key), + pullEncrypter(key, nonce), // the encrypted stream pull.through((ciphertext) => { @@ -19,7 +21,7 @@ pull( }), // decrypt every byte - pullDecrypter(key), + pullDecrypter(key, nonce), pull.concat((err, plaintext2) => { if (err) throw err diff --git a/js/pull-secret-channel/src/index.js b/js/pull-secret-channel/src/index.js index 3693c16..9da35e5 100644 --- a/js/pull-secret-channel/src/index.js +++ b/js/pull-secret-channel/src/index.js @@ -4,6 +4,7 @@ const { createEncrypter, createDecrypter, KEY_SIZE, + NONCE_SIZE, LENGTH_OR_END_CIPHERTEXT, TAG_SIZE, } = require('secret-channel') @@ -12,11 +13,12 @@ module.exports = { pullEncrypter, pullDecrypter, KEY_SIZE, + NONCE_SIZE, TAG_SIZE, } -function pullEncrypter(key) { - const encrypter = createEncrypter(key) +function pullEncrypter(key, nonce) { + const encrypter = createEncrypter(key, nonce) return pullThrough( function pullEncrypterData(contentPlaintext) { @@ -33,8 +35,8 @@ function pullEncrypter(key) { ) } -function pullDecrypter(key) { - const decrypter = createDecrypter(key) +function pullDecrypter(key, nonce) { + const decrypter = createDecrypter(key, nonce) let ending = null const reader = pullReader() diff --git a/js/pull-secret-channel/test/basics.js b/js/pull-secret-channel/test/basics.js index d2d40f9..bf977cc 100644 --- a/js/pull-secret-channel/test/basics.js +++ b/js/pull-secret-channel/test/basics.js @@ -2,22 +2,24 @@ const test = require('node:test') const assert = require('node:assert') const pull = require('pull-stream') const { randomBytes } = require('crypto') -const { KEY_SIZE, pullEncrypter, pullDecrypter } = require('../') +const { pullEncrypter, pullDecrypter, KEY_SIZE, NONCE_SIZE } = require('../') test('test basic encrypt and decrypt', 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), + pullEncrypter(key, nonce), pull.through((ciphertext) => { console.log('Encrypted: ', ciphertext) }), - pullDecrypter(key), + pullDecrypter(key, nonce), pull.concat((err, plaintext2) => { if (err) return reject(err) assert.equal(plaintext2.toString('utf8'), plaintext1.toString('utf8')) diff --git a/js/secret-channel/src/constants.js b/js/secret-channel/src/constants.js index 0c890f6..9b3450b 100644 --- a/js/secret-channel/src/constants.js +++ b/js/secret-channel/src/constants.js @@ -1,4 +1,5 @@ const KEY_SIZE = 32 +const NONCE_SIZE = 12 const TAG_SIZE = 16 const LENGTH_OR_END_PLAINTEXT = 2 @@ -6,6 +7,7 @@ const LENGTH_OR_END_CIPHERTEXT = LENGTH_OR_END_PLAINTEXT + TAG_SIZE module.exports = { KEY_SIZE, + NONCE_SIZE, TAG_SIZE, LENGTH_OR_END_PLAINTEXT, LENGTH_OR_END_CIPHERTEXT, diff --git a/js/secret-channel/src/protocol.js b/js/secret-channel/src/protocol.js index fba9753..7814ce5 100644 --- a/js/secret-channel/src/protocol.js +++ b/js/secret-channel/src/protocol.js @@ -1,41 +1,43 @@ const b4a = require('b4a') -const { KEY_SIZE, LENGTH_OR_END_PLAINTEXT, LENGTH_OR_END_CIPHERTEXT } = require('./constants') - -const NONCE_SIZE = 12 - -class StreamCounter { - #increment - #nonce - - constructor(increment) { - this.#increment = increment - this.#nonce = b4a.alloc(NONCE_SIZE, 0) - } - - next() { - this.#increment(this.#nonce) - return this.#nonce - } -} - -class StreamEncrypter { +const { + KEY_SIZE, + NONCE_SIZE, + LENGTH_OR_END_PLAINTEXT, + LENGTH_OR_END_CIPHERTEXT, +} = require('./constants') + +class Encrypter { #crypto #key - #counter + #nonce - constructor(crypto, key) { + constructor(crypto, key, nonce) { this.#crypto = crypto + if (!b4a.isBuffer(key)) { + throw new Error('secret-channel/Encrypter: key must be a buffer') + } if (key.length !== KEY_SIZE) { - throw new Error(`secret-channel/StreamEncrypter: key must be ${KEY_SIZE} bytes`) + throw new Error(`secret-channel/Encrypter: key must be ${KEY_SIZE} bytes`) } this.#key = key - this.#counter = new StreamCounter(crypto.increment) + if (!b4a.isBuffer(nonce)) { + throw new Error('secret-channel/Encrypter: nonce must be a buffer') + } + if (nonce.length !== NONCE_SIZE) { + throw new Error(`secret-channel/Encrypter: nonce must be ${NONCE_SIZE} bytes`) + } + // clone the nonce so is owned and mutable + this.#nonce = b4a.allocUnsafe(NONCE_SIZE) + b4a.copy(nonce, this.#nonce) } next(plaintext) { + if (this.#key === null) { + throw new Error('secret-channel/Encrypter: stream has already ended') + } const plaintextBuffer = b4a.from(plaintext) const length = this.#chunkLength(plaintextBuffer.length) const content = this.#chunkContent(plaintextBuffer) @@ -43,15 +45,18 @@ class StreamEncrypter { } end() { + if (this.#key === null) { + throw new Error('secret-channel/Encrypter: stream has already ended') + } const eos = this.#chunkEndOfStream() - // TODO delete the key + this.#key = null return eos } #chunkLength(length) { const lengthData = b4a.allocUnsafe(LENGTH_OR_END_PLAINTEXT) const lengthDataView = new DataView(lengthData.buffer, lengthData.byteOffset, lengthData.length) - lengthDataView.setInt16(0, length, true) + lengthDataView.setInt16(0, length, false) return this.#encrypt(lengthData) } @@ -65,38 +70,55 @@ class StreamEncrypter { } #encrypt(bytes) { - const nonce = this.#counter.next() - return this.#crypto.encrypt(this.#key, nonce, bytes) + const ciphertext = this.#crypto.encrypt(this.#key, this.#nonce, bytes) + this.#crypto.increment(this.#nonce) + return ciphertext } } -class StreamDecrypter { +class Decrypter { #crypto #key - #counter + #nonce - constructor(crypto, key) { + constructor(crypto, key, nonce) { this.#crypto = crypto + if (!b4a.isBuffer(key)) { + throw new Error('secret-channel/Decrypter: key must be a buffer') + } if (key.length !== KEY_SIZE) { - throw new Error(`secret-channel/StreamDecrypter: key must be ${KEY_SIZE} bytes`) + throw new Error(`secret-channel/Decrypter: key must be ${KEY_SIZE} bytes`) } this.#key = key - this.#counter = new StreamCounter(crypto.increment) + if (!b4a.isBuffer(nonce)) { + throw new Error('secret-channel/Decrypter: nonce must be a buffer') + } + if (nonce.length !== NONCE_SIZE) { + throw new Error(`secret-channel/Encrypter: nonce must be ${NONCE_SIZE} bytes`) + } + // clone the nonce so is owned and mutable + this.#nonce = b4a.allocUnsafe(NONCE_SIZE) + b4a.copy(nonce, this.#nonce) } lengthOrEnd(ciphertext) { + if (this.#key === null) { + throw new Error('secret-channel/Decrypter: stream has already ended') + } + if (ciphertext.length !== LENGTH_OR_END_CIPHERTEXT) { throw new Error( - `secret-channel/StreamDecrypter: length / end ciphertext must be ${LENGTH_OR_END_CIPHERTEXT} bytes`, + `secret-channel/Decrypter: length / end ciphertext must be ${LENGTH_OR_END_CIPHERTEXT} bytes`, ) } const plaintext = this.#decrypt(ciphertext) if (this.#crypto.isZero(plaintext)) { - // TODO delete the key + // delete the key + this.#key = null return { type: 'end-of-stream', } @@ -104,7 +126,7 @@ class StreamDecrypter { const lengthData = plaintext const lengthDataView = new DataView(lengthData.buffer, lengthData.byteOffset, lengthData.length) - const length = lengthDataView.getInt16(0, true) + const length = lengthDataView.getInt16(0, false) return { type: 'length', length, @@ -112,22 +134,26 @@ class StreamDecrypter { } content(ciphertext) { + if (this.#key === null) { + throw new Error('secret-channel/Decrypter: stream has already ended') + } return this.#decrypt(ciphertext) } #decrypt(bytes) { - const nonce = this.#counter.next() - return this.#crypto.decrypt(this.#key, nonce, bytes) + const plaintext = this.#crypto.decrypt(this.#key, this.#nonce, bytes) + this.#crypto.increment(this.#nonce) + return plaintext } } function protocol(crypto) { return { - createEncrypter(key) { - return new StreamEncrypter(crypto, key) + createEncrypter(key, nonce) { + return new Encrypter(crypto, key, nonce) }, - createDecrypter(key) { - return new StreamDecrypter(crypto, key) + createDecrypter(key, nonce) { + return new Decrypter(crypto, key, nonce) }, } } diff --git a/js/secret-channel/test/basics.js b/js/secret-channel/test/basics.js index 9370058..b0c47f4 100644 --- a/js/secret-channel/test/basics.js +++ b/js/secret-channel/test/basics.js @@ -3,17 +3,18 @@ const test = require('node:test') const assert = require('node:assert') const { randomBytes } = require('node:crypto') -const { createEncrypter, KEY_SIZE, createDecrypter } = require('../') +const { createEncrypter, createDecrypter, KEY_SIZE, NONCE_SIZE } = require('../') test('roundtrip hello world', async (t) => { // generate a random secret, `KEY_SIZE` bytes long const key = randomBytes(KEY_SIZE) + const nonce = randomBytes(NONCE_SIZE) const contentPlaintext1 = b4a.from('hello') const contentPlaintext2 = b4a.from('world') - const encrypter = createEncrypter(key) - const decrypter = createDecrypter(key) + const encrypter = createEncrypter(key, nonce) + const decrypter = createDecrypter(key, nonce) const [lengthCiphertext1, contentCiphertext1] = encrypter.next(contentPlaintext1) const [lengthCiphertext2, contentCiphertext2] = encrypter.next(contentPlaintext2) diff --git a/js/secret-channel/test/same-across-native-and-javascript.js b/js/secret-channel/test/same-across-native-and-javascript.js index b501192..f440316 100644 --- a/js/secret-channel/test/same-across-native-and-javascript.js +++ b/js/secret-channel/test/same-across-native-and-javascript.js @@ -3,20 +3,22 @@ const test = require('node:test') const assert = require('node:assert') const { randomBytes, randomInt } = require('node:crypto') +const { KEY_SIZE, NONCE_SIZE } = require('../src/constants') const native = require('../src/index') const js = require('../src/javascript') test('encrypt: native and javascript are the same', async (t) => { // generate a random secret, `KEYBYTES` bytes long. - const key = randomBytes(native.KEY_SIZE) + const key = randomBytes(KEY_SIZE) + const nonce = randomBytes(NONCE_SIZE) const contents = [] for (let i = 0; i < randomInt(100, 1000); i++) { contents.push(randomBytes(randomInt(10, 100))) } - const nativeEncrypter = native.createEncrypter(key) - const jsEncrypter = js.createEncrypter(key) + const nativeEncrypter = native.createEncrypter(key, nonce) + const jsEncrypter = js.createEncrypter(key, nonce) for (let i = 0; i < contents.length; i++) { const content = contents[i]