Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Encrypted UDP messages #39

Merged
merged 5 commits into from
Oct 21, 2024
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
62 changes: 38 additions & 24 deletions index.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,6 @@ const Handshake = require('./lib/handshake')
const IDHEADERBYTES = HEADERBYTES + 32
const [NS_INITIATOR, NS_RESPONDER, NS_BOX] = crypto.namespace('hyperswarm/secret-stream', 3)
mafintosh marked this conversation as resolved.
Show resolved Hide resolved
const MAX_ATOMIC_WRITE = 256 * 256 * 256 - 1
const MAX_NONCE_PREFIX = b4a.fill(b4a.allocUnsafeSlow(8), 0xff)

module.exports = class NoiseSecretStream extends Duplex {
constructor (isInitiator, rawStream, opts = {}) {
Expand Down Expand Up @@ -72,8 +71,7 @@ module.exports = class NoiseSecretStream extends Duplex {
this._timeoutTimer = null
this._keepAliveTimer = null
this._boxSecret = null
this._boxNonceEncrypt = null
this._boxNonceDecrypt = null
this._boxSeq = null

if (opts.autoStart !== false) this.start(rawStream, opts)

Expand Down Expand Up @@ -383,16 +381,8 @@ module.exports = class NoiseSecretStream extends Duplex {
const id = buf.subarray(3, 3 + 32)
streamId(handshakeHash, this.isInitiator, id)

// setup secretbox state for unordered messages
this._boxSecret = b4a.allocUnsafeSlow(32)
this._boxNonceEncrypt = b4a.allocUnsafeSlow(sodium.crypto_secretbox_NONCEBYTES)
this._boxNonceDecrypt = b4a.allocUnsafeSlow(sodium.crypto_secretbox_NONCEBYTES)

sodium.crypto_generichash(this._boxSecret, NS_BOX, handshakeHash)
sodium.crypto_generichash(this._boxNonceEncrypt, b4a.concat([NS_BOX, publicKey]), handshakeHash)
sodium.crypto_generichash(this._boxNonceDecrypt, b4a.concat([NS_BOX, remotePublicKey]), handshakeHash)
b4a.fill(this._boxNonceDecrypt, 0, 0, 8) // zerofill first 8 bytes
b4a.fill(this._boxNonceEncrypt, 0, 0, 8)
// initialize secretbox state for unordered messages
this._setupSecretBox(handshakeHash)

this.emit('handshake')
// if rawStream is a bridge, also emit it there
Expand All @@ -403,6 +393,20 @@ module.exports = class NoiseSecretStream extends Duplex {
this._rawStream.write(buf)
}

_setupSecretBox (handshakeHash) {
this._boxSecret = b4a.allocUnsafeSlow(64) // encrypt<32>, decrypt<32>
this._boxSeq = b4a.allocUnsafeSlow(16) // increment<8>, initial<8>
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

alloc both in one go, and just store the subarrays

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

gotcha


const inputs = [b4a.concat([NS_INITIATOR, NS_BOX]), b4a.concat([NS_RESPONDER, NS_BOX])]
if (!this.isInitiator) inputs.reverse()
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

const inputs = this.isInitiator ? [...] : [...]

instead


sodium.crypto_generichash(this._boxSecret.subarray(0, 32), inputs[0], handshakeHash)

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

if you use crypto_generichash_batch here you don't have to concat the inputs, just pass the array directly

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

oh nice, yes fixing

sodium.crypto_generichash(this._boxSecret.subarray(32), inputs[1], handshakeHash)

sodium.randombytes_buf(this._boxSeq.subarray(8))
this._boxSeq.set(this._boxSeq.subarray(8))
}

_open (cb) {
// no autostart or no handshake yet
if (this._rawStream === null || (this._handshake === null && this._encrypt === null)) {
Expand Down Expand Up @@ -517,17 +521,22 @@ module.exports = class NoiseSecretStream extends Duplex {
}

_boxMessage (buffer) {
const nonce = this._boxNonceEncrypt
const prefix = nonce.subarray(0, 8)
if (b4a.equals(prefix, MAX_NONCE_PREFIX)) throw new Error('nonce exhausted')
const MB = sodium.crypto_secretbox_MACBYTES // 16
const NB = sodium.crypto_secretbox_NONCEBYTES // 24

const prefix = this._boxSeq.subarray(0, 8)
sodium.sodium_increment(prefix)
if (b4a.equals(prefix, this._boxSeq.subarray(8))) throw new Error('nonce exhausted')

const envelope = b4a.allocUnsafe(8 + buffer.length + sodium.crypto_secretbox_MACBYTES)
const secret = this._boxSecret.subarray(0, 32)
const envelope = b4a.allocUnsafe(8 + MB + buffer.length)
mafintosh marked this conversation as resolved.
Show resolved Hide resolved
const nonce = envelope.subarray(0, NB)
const ciphertext = envelope.subarray(8)

sodium.crypto_secretbox_easy(ciphertext, buffer, nonce, this._boxSecret)
envelope.set(prefix)
nonce.set(this.remotePublicKey.subarray(0, NB)) // pad suffix
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

just pad with zeros is fine

nonce.set(prefix)

sodium.sodium_increment(prefix)
sodium.crypto_secretbox_easy(ciphertext, buffer, nonce, secret)
return envelope
}

Expand All @@ -550,13 +559,18 @@ module.exports = class NoiseSecretStream extends Duplex {
_onmessage (buffer) {
if (!this._boxSecret) return // messages before handshake are dropped

const nonce = this._boxNonceDecrypt
nonce.set(buffer.subarray(0, 8)) // update prefix
const MB = sodium.crypto_secretbox_MACBYTES // 16
const NB = sodium.crypto_secretbox_NONCEBYTES // 24

const nonce = b4a.allocUnsafe(NB)
nonce.set(this.publicKey.subarray(0, NB))
nonce.set(buffer.subarray(0, 8))

const secret = this._boxSecret.subarray(32)
const ciphertext = buffer.subarray(8)
const plain = buffer.subarray(8, buffer.length - sodium.crypto_secretbox_MACBYTES)
const plain = buffer.subarray(8, buffer.length - MB)
mafintosh marked this conversation as resolved.
Show resolved Hide resolved

const success = sodium.crypto_secretbox_open_easy(plain, ciphertext, nonce, this._boxSecret)
const success = sodium.crypto_secretbox_open_easy(plain, ciphertext, nonce, secret)

if (success) this.emit('message', plain)
}
Expand Down
47 changes: 37 additions & 10 deletions test.js
Original file line number Diff line number Diff line change
Expand Up @@ -640,20 +640,32 @@ test('basic - unslab checks', function (t) {
})
})

test('encrypted unordered message', async function (t) {
const message = Buffer.from('plaintext', 'utf8')
function udxPair () {
const u = new UDX()
const socket1 = u.createSocket()
const socket2 = u.createSocket()
socket1.bind()
socket2.bind()
for (const s of [socket1, socket2]) s.bind()

const stream1 = u.createStream(1)
const stream2 = u.createStream(2)
stream1.connect(socket1, stream2.id, socket2.address().port, '127.0.0.1')
stream2.connect(socket2, stream1.id, socket1.address().port, '127.0.0.1')
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

there is a test handler you can import from udx also that does this for you if you want (makeStreamPair i think it is in test/helpers)

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yup found it, but i dosen't seem like the test/ folder is included in the npm-distribution. can't import


const a = new NoiseStream(true, stream1)
const b = new NoiseStream(false, stream2)
return [
new NoiseStream(true, stream1),
new NoiseStream(false, stream2),

async () => {
for (const stream of [stream1, stream2]) stream.end()
await socket1.close()
await socket2.close()
}
]
}

test('encrypted unordered message', async function (t) {
const [a, b, destroy] = udxPair()
const message = Buffer.from('plaintext', 'utf8')
mafintosh marked this conversation as resolved.
Show resolved Hide resolved

const transmission1 = new Promise(resolve => b.once('message', resolve))

Expand All @@ -672,8 +684,23 @@ test('encrypted unordered message', async function (t) {
const m1 = await transmission2
t.ok(m1.equals(message), 'trySend(): received & decrypted')

stream1.end()
stream2.end()
await socket1.close()
await socket2.close()
await destroy()
})

test.skip('message fragmentation', async t => {
const [a, b, destroy] = udxPair()
const burstMTU = Buffer.allocUnsafe(6000) // UDP MTU ~1500 bytes

const transmission = new Promise(resolve => b.once('message', resolve))

await a.opened
await b.opened

await a.send(burstMTU)
console.log('big buffer sent')

const received = await transmission
t.ok(received.equals(burstMTU), 'fragmentation works')

await destroy()
})
Loading