Skip to content

Commit

Permalink
Start with random nonce and increment, change content length to big-e…
Browse files Browse the repository at this point in the history
…ndian (#7)
  • Loading branch information
ahdinosaur committed Nov 27, 2023
1 parent 21a7bc5 commit 1398ece
Show file tree
Hide file tree
Showing 9 changed files with 185 additions and 79 deletions.
6 changes: 2 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
@@ -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)).

Expand All @@ -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.

Expand Down
105 changes: 88 additions & 17 deletions SPEC.md
Original file line number Diff line number Diff line change
@@ -1,19 +1,33 @@
# "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.

- 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
+---------------------+-------------------------------------------------------+
Expand All @@ -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
Expand All @@ -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)

Expand Down Expand Up @@ -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).
Expand All @@ -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)
8 changes: 5 additions & 3 deletions js/pull-secret-channel/examples/roundtrip.js
Original file line number Diff line number Diff line change
@@ -1,25 +1,27 @@
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')

pull(
pull.values([plaintext1]),

// encrypt every byte
pullEncrypter(key),
pullEncrypter(key, nonce),

// the encrypted stream
pull.through((ciphertext) => {
console.log('Encrypted: ', ciphertext)
}),

// decrypt every byte
pullDecrypter(key),
pullDecrypter(key, nonce),

pull.concat((err, plaintext2) => {
if (err) throw err
Expand Down
10 changes: 6 additions & 4 deletions js/pull-secret-channel/src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ const {
createEncrypter,
createDecrypter,
KEY_SIZE,
NONCE_SIZE,
LENGTH_OR_END_CIPHERTEXT,
TAG_SIZE,
} = require('secret-channel')
Expand All @@ -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) {
Expand All @@ -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()
Expand Down
8 changes: 5 additions & 3 deletions js/pull-secret-channel/test/basics.js
Original file line number Diff line number Diff line change
Expand Up @@ -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'))
Expand Down
2 changes: 2 additions & 0 deletions js/secret-channel/src/constants.js
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
const KEY_SIZE = 32
const NONCE_SIZE = 12
const TAG_SIZE = 16

const LENGTH_OR_END_PLAINTEXT = 2
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,
Expand Down

0 comments on commit 1398ece

Please sign in to comment.