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 all commits
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
10 changes: 10 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,16 @@ Emitted when the handshake is fully done.
It is safe to write to the stream immediately though, as data is buffered
internally before the handshake has been completed.

#### `await s.send(buffer)`
Sends an encrypted unordered message, see [udx-native](https://github.com/holepunchto/udx-native/tree/main?tab=readme-ov-file#await-streamsendbuffer) for details.
This method with silently fail if called before handshake is complete or if the underlying rawStream is not an UDX-stream (not capable of UDP).

#### `s.trySend(buffer)`
Same as `send(buffer)` but does not return a promise.

#### `s.on('message', onmessage)`
Emmitted when an unordered message is received

#### `keyPair = SecretStream.keyPair([seed])`

Generate a ed25519 key pair.
Expand Down
82 changes: 81 additions & 1 deletion index.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ const Bridge = require('./lib/bridge')
const Handshake = require('./lib/handshake')

const IDHEADERBYTES = HEADERBYTES + 32
const [NS_INITIATOR, NS_RESPONDER] = crypto.namespace('hyperswarm/secret-stream', 2)
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

module.exports = class NoiseSecretStream extends Duplex {
Expand Down Expand Up @@ -70,6 +70,7 @@ module.exports = class NoiseSecretStream extends Duplex {
this._decrypt = null
this._timeoutTimer = null
this._keepAliveTimer = null
this._sendState = null

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

Expand Down Expand Up @@ -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.


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

_setupSecretSend (handshakeHash) {
this._sendState = b4a.allocUnsafeSlow(32 + 32 + 8 + 8)
const encrypt = this._sendState.subarray(0, 32) // secrets
const decrypt = this._sendState.subarray(32, 64)
const counter = this._sendState.subarray(64, 72) // nonce
const initial = this._sendState.subarray(72)

const inputs = this.isInitiator
? [[NS_INITIATOR, NS_BOX], [NS_RESPONDER, NS_BOX]]
: [[NS_RESPONDER, NS_BOX], [NS_INITIATOR, NS_BOX]]

sodium.crypto_generichash_batch(encrypt, inputs[0], handshakeHash)
sodium.crypto_generichash_batch(decrypt, inputs[1], handshakeHash)

sodium.randombytes_buf(initial)
counter.set(initial)
}

_open (cb) {
// no autostart or no handshake yet
if (this._rawStream === null || (this._handshake === null && this._encrypt === null)) {
Expand All @@ -398,6 +420,7 @@ module.exports = class NoiseSecretStream extends Duplex {
this._rawStream.on('data', this._onrawdata.bind(this))
this._rawStream.on('end', this._onrawend.bind(this))
this._rawStream.on('drain', this._onrawdrain.bind(this))
this._rawStream.on('message', this._onmessage.bind(this))

if (this._encrypt !== null) {
this._resolveOpened(true)
Expand Down Expand Up @@ -500,6 +523,63 @@ module.exports = class NoiseSecretStream extends Duplex {
cb(null)
}

_boxMessage (buffer) {
const MB = sodium.crypto_secretbox_MACBYTES // 16
const NB = sodium.crypto_secretbox_NONCEBYTES // 24

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..

}

const secret = this._sendState.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)

b4a.fill(nonce, 0) // pad suffix
nonce.set(counter)

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

send (buffer) {
if (!this._sendState) return
if (!this.rawStream?.send) return // udx-stream expected

const message = this._boxMessage(buffer)
return this.rawStream.send(message)
}

trySend (buffer) {
if (!this._sendState) return
if (!this.rawStream?.trySend) return // udx-stream expected

const message = this._boxMessage(buffer)
mafintosh marked this conversation as resolved.
Show resolved Hide resolved
this.rawStream.trySend(message)
}

_onmessage (buffer) {
if (!this._sendState) return // messages before handshake are dropped

const MB = sodium.crypto_secretbox_MACBYTES // 16
const NB = sodium.crypto_secretbox_NONCEBYTES // 24

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

const secret = this._sendState.subarray(32, 64)
const ciphertext = buffer.subarray(8)
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, secret)

if (success) this.emit('message', plain)
}

alloc (len) {
const buf = b4a.allocUnsafe(len + 3 + ABYTES)
this._outgoingWrapped = buf
Expand Down
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,8 @@
},
"devDependencies": {
"brittle": "^3.3.0",
"standard": "^17.1.0"
"standard": "^17.1.0",
"udx-native": "^1.13.2"
},
"scripts": {
"test": "standard && brittle test.js"
Expand Down
48 changes: 48 additions & 0 deletions test.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ const Events = require('events')
const crypto = require('crypto')
const { Readable, Duplex } = require('streamx')
const NoiseStream = require('./')
const UDX = require('udx-native')

test('basic', function (t) {
t.plan(2)
Expand Down Expand Up @@ -638,3 +639,50 @@ test('basic - unslab checks', function (t) {
t.ok(pull.state.buffer.byteLength < 100, 'pull.state.buffer no slab')
})
})

function udxPair () {
const u = new UDX()
const socket1 = u.createSocket()
const socket2 = u.createSocket()
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


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))

await a.opened
await b.opened

await a.send(message)

const m0 = await transmission1
t.ok(m0.equals(message), 'send(): received & decrypted')

const transmission2 = new Promise(resolve => a.once('message', resolve))

b.trySend(message)

const m1 = await transmission2
t.ok(m1.equals(message), 'trySend(): received & decrypted')

await destroy()
})
Loading