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

Encrypted UDP messages #39

merged 5 commits into from
Oct 21, 2024

Conversation

telamon
Copy link
Contributor

@telamon telamon commented Oct 15, 2024

In order to send private unordered messages (live-streams or updates / UDP-style)

I've lifted udx-stream's await send(buffer), trySend(buffer) and on('message', onmessage) API
as an encrypted variant.

I'm not sure about:

  • _onmessage(buffer) when handshake is pending, the buffer is pushed onto the eventqueue. Do I need to copy the message to free/detach it from udx's receive buffers?
  • in send and trySend what happens if buffer exceeds MTU?
  • in _onmessage(buffer) and _boxMessage(buffer) when handshake is awaited, what happens if handshake fails?

index.js Outdated
// initialize secretbox for unordered messages
this._boxSecret = b4a.allocUnsafe(32)
sodium.crypto_generichash(this._boxSecret, NS_BOX, handshakeHash)
this._boxNonce = b4a.allocUnsafe(sodium.crypto_secretbox_NONCEBYTES)
Copy link
Contributor

Choose a reason for hiding this comment

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

use allocUnsafeSlow for these long lived ones to avoid

index.js Outdated
@@ -500,6 +509,38 @@ module.exports = class NoiseSecretStream extends Duplex {
cb(null)
}

async _boxMessage (buffer) {
if (!this._boxSecret) await new Promise(resolve => this.once('handshake', resolve))
Copy link
Contributor

Choose a reason for hiding this comment

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

dont rely on events for this, i think we have a promise in the constructor you can await, otherwise lets add

Copy link
Contributor

Choose a reason for hiding this comment

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

actually see comment below about moving this part out

index.js Outdated
this._boxSecret = b4a.allocUnsafe(32)
sodium.crypto_generichash(this._boxSecret, NS_BOX, handshakeHash)
this._boxNonce = b4a.allocUnsafe(sodium.crypto_secretbox_NONCEBYTES)
sodium.randombytes_buf(this._boxNonce)
Copy link
Contributor

Choose a reason for hiding this comment

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

@chm-diederichs do we wanna use something more fancy here or is this ok you think? eats 24 bytes, but i am guessing the minimal we can do is 8 bytes (uint64) so mb this is worth it for simplicity

Copy link

@chm-diederichs chm-diederichs Oct 16, 2024

Choose a reason for hiding this comment

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

yeh we could just fix the first 24* bytes to be hash(NS_NONCE, handshakeHash) and then append the uint64 bytes, not much complexity in that so probably seems worth it?

*edit: first 16 bytes

Copy link
Contributor

Choose a reason for hiding this comment

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

you mean eat 32 bytes instead? would like to keep this as small as possible but obvs a trade off with complexity, the udp package is in practice capped at ~1kb so less metadata is better

Choose a reason for hiding this comment

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

i mean we do:

this._boxNonce = b4a.alloc(NONCEBYTES)
this._activeNonce = this._boxNonce.subarray(NONCEBYTES - 8)

sodium.crypto_generichash(this._boxNonce, NS_NONCE, handshakeHash)
this._activeNonce.fill(0)

// ... box message
const envelope = b4a.alloc(8 + buffer.length + MACBYTES) // save 16 bytes

envelope.set(this._activeNonce)
sodium_increment(this._activeNonce)

Choose a reason for hiding this comment

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

that gives unique nonces per session and uint64_max messages (prob need to bound check the nonce as well..)

Copy link
Contributor

Choose a reason for hiding this comment

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

ah yes, ok, i was thinking it was just line in hc where we blind it, but this is also fine.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

chiming in, hmm.. truncating the transmitted nonce to uint64 is good in 1K constraints.
But i get a feeling that if both ends use the same secret + nonce-interval we'll end up encrypting different messages with same nonce.

Maybe also replace _boxSecret with?:

this._boxEncrypt = b4a.allocUnsafeSlow(32)
this._boxDecrypt = b4a.allocUnsafeSlow(32)

sodium.crypto_generichash(this._boxEncrypt, this.isInitiator ? NS_BOX_INITIATOR : NS_BOX_RESPONDER, handshakeHash)
sodium.crypto_generichash(this._boxDecrypt, this.isInitiator ? NS_BOX_RESPONDER : NS_BOX_INITIATOR, handshakeHash)

Honestly, i'm not sure what to do on a triggered bounds check, like attempt to reroll a new nonce on both sides?
If that operation was painless/safe we could further compress transmitted nonce to uint32

Copy link

@chm-diederichs chm-diederichs Oct 16, 2024

Choose a reason for hiding this comment

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

Good point, the initiator/responder nonces should be different. I think the easiest way is to just hash in this.publicKey as well, eg. sodium.crypto_generichash_batch(this._boxSecret, [NS_NONCE, this.publicKey], handshakeHash)

As for the bounds check, throwing an error is fine as we won't ever hit this in practice

index.js Outdated

async send (buffer) {
if (!this.rawStream?.send) return // udx-stream expected
const message = await this._boxMessage(buffer)
Copy link
Contributor

Choose a reason for hiding this comment

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

i would move the handshake wait here instead of in box message, ie

if (!opened) await whateverOpensIt

index.js Outdated

trySend (buffer) {
if (!this.rawStream?.trySend) return // udx-stream expected
this._boxMessage(buffer)
Copy link
Contributor

Choose a reason for hiding this comment

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

similar to above, if not opened, would just drop the message here, simpler

index.js Outdated
trySend (buffer) {
if (!this.rawStream?.trySend) return // udx-stream expected
this._boxMessage(buffer)
.then(message => this.rawStream.send(message))
Copy link
Contributor

Choose a reason for hiding this comment

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

should call trySend on rawStream

index.js Outdated
}

async _onmessage (buffer) {
if (!this._boxSecret) await new Promise(resolve => this.once('handshake', resolve))
Copy link
Contributor

Choose a reason for hiding this comment

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

would just drop it if the handshake isnt done and have this be sync (its 1 RT in practice so requires intense reordering to not be the case and even then its udp so we can do stuff like that)

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Yes that simplifies things, i had a note somewhere that messages should be buffered. But as you say, it's UDP. Critical messages should go through write/data not send/message

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

index.js Outdated
@@ -376,6 +378,12 @@ module.exports = class NoiseSecretStream extends Duplex {
this.remotePublicKey = remotePublicKey
this.handshakeHash = handshakeHash

// initialize secretbox for unordered messages
this._boxSecret = b4a.allocUnsafe(32)

Choose a reason for hiding this comment

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

One tiny nit, it's easier to review crypto if all the sodium calls are grouped like so:

this._boxSecret = b4a.alloc(..)
this._boxNonce = b4a.alloc(..)

sodium.crypto_generichash(this._boxSecret...)
sodium.randombytes_buf(this._boxNonce)

Choose a reason for hiding this comment

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

in this case, it's quite straightforward so a newline before allocing the nonce would also work

- reduced nonce-overhead to 8bytes
- reimplement api as sync
- added nonce bounds check
- fixed typo
- documented message drop conditions
index.js Outdated
sodium.crypto_secretbox_easy(ciphertext, buffer, nonce, this._boxSecret)
envelope.set(prefix)

sodium.sodium_increment(prefix)

Choose a reason for hiding this comment

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

maybe move this to an _increment method that does the bounds check as well?

Copy link

@chm-diederichs chm-diederichs Oct 16, 2024

Choose a reason for hiding this comment

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

ahh nevermind actually, it's nicer as it is 🥳

index.js Outdated

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)
Copy link
Contributor

Choose a reason for hiding this comment

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

normally we just use two namesapces for this, ie _INITIATOR and _RESPONDER and then use the isInitiator state to reflect that (this also works tho, but just divergence froom that pattern)

Copy link
Contributor

Choose a reason for hiding this comment

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

actually you dont need to store these, the rest of the nonce can just be 0 bytes - thats perfectly fine

index.js Outdated
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)
Copy link
Contributor

Choose a reason for hiding this comment

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

instead of resetting this to 0, should be just save the initial buffer and use that for the overflow check instead? less 0000 bits over the wire

Copy link
Contributor

Choose a reason for hiding this comment

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

cc @chm-diederichs on this also

index.js Outdated
this._boxNonceEncrypt = b4a.allocUnsafeSlow(sodium.crypto_secretbox_NONCEBYTES)
this._boxNonceDecrypt = b4a.allocUnsafeSlow(sodium.crypto_secretbox_NONCEBYTES)

sodium.crypto_generichash(this._boxSecret, NS_BOX, handshakeHash)
Copy link
Contributor

Choose a reason for hiding this comment

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

we should have two secret keys, one for each side, use the flow below to generate two

- separated boxSecret into encrypt and decrypt key
- random initial 8B nonce + bound check
- added test for >MTU transmissions
index.js Outdated
const inputs = [b4a.concat([NS_INITIATOR, NS_BOX]), b4a.concat([NS_RESPONDER, NS_BOX])]
if (!this.isInitiator) inputs.reverse()

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

index.js Outdated
this._boxSeq = b4a.allocUnsafeSlow(16) // increment<8>, initial<8>

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

index.js Outdated
@@ -388,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

index.js Outdated
const nonce = envelope.subarray(0, NB)
const ciphertext = envelope.subarray(8)

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

index.js Show resolved Hide resolved
- renamed _boxSecret & _boxSeq => _sendState
- renamed prefix|increment => counter
- zero padded nonces
- used hash_batch instead of concats
@@ -379,6 +380,9 @@ module.exports = class NoiseSecretStream extends Duplex {
const id = buf.subarray(3, 3 + 32)
streamId(handshakeHash, this.isInitiator, id)

// initialize secretbox state for unordered messages
this._setupSecretSend(handshakeHash)
Copy link
Contributor Author

Choose a reason for hiding this comment

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

self-nitpick, this function doesn't technically have to be a class member anymore:

this._sendState = initializeSend(handshakeHash)

but the current version is probably easier on the eyes.

@telamon telamon self-assigned this Oct 21, 2024
@telamon telamon merged commit 9a26004 into main Oct 21, 2024
4 checks passed
const counter = this._sendState.subarray(64, 72)
sodium.sodium_increment(counter)
if (b4a.equals(counter, this._sendState.subarray(72))) {
this.destroy(new Error('udp send nonce exchausted'))
Copy link
Contributor

Choose a reason for hiding this comment

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

missing a return after this line

Copy link
Contributor Author

Choose a reason for hiding this comment

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

good catch..

index.js Show resolved Hide resolved
index.js Show resolved Hide resolved
test.js Show resolved Hide resolved
index.js Show resolved Hide resolved
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

3 participants