-
Notifications
You must be signed in to change notification settings - Fork 14
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
Conversation
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) |
There was a problem hiding this comment.
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)) |
There was a problem hiding this comment.
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
There was a problem hiding this comment.
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) |
There was a problem hiding this comment.
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
There was a problem hiding this comment.
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
There was a problem hiding this comment.
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
There was a problem hiding this comment.
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)
There was a problem hiding this comment.
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..)
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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
There was a problem hiding this comment.
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) |
There was a problem hiding this comment.
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) |
There was a problem hiding this comment.
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)) |
There was a problem hiding this comment.
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)) |
There was a problem hiding this comment.
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)
There was a problem hiding this comment.
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') |
There was a problem hiding this comment.
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)
There was a problem hiding this comment.
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) |
There was a problem hiding this comment.
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)
There was a problem hiding this comment.
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) |
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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) |
There was a problem hiding this comment.
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)
There was a problem hiding this comment.
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) |
There was a problem hiding this comment.
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
There was a problem hiding this comment.
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) |
There was a problem hiding this comment.
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) |
There was a problem hiding this comment.
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
There was a problem hiding this comment.
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() |
There was a problem hiding this comment.
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> |
There was a problem hiding this comment.
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
There was a problem hiding this comment.
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 |
There was a problem hiding this comment.
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
- 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) |
There was a problem hiding this comment.
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.
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')) |
There was a problem hiding this comment.
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
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
good catch..
In order to send private unordered messages (live-streams or updates / UDP-style)
I've lifted udx-stream's
await send(buffer)
,trySend(buffer)
andon('message', onmessage)
APIas 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?send
andtrySend
what happens if buffer exceeds MTU?_onmessage(buffer)
and_boxMessage(buffer)
when handshake is awaited, what happens if handshake fails?