Skip to content

Commit 9b04145

Browse files
authored
fix: improve nonce performance (#162)
* feat: improve nonce performance Introduce Nonce class to maintain both Uint8Array and DataView and uint64 so that we can remove nonceToBytes function * chore: keep the encrypt() and decrypt() interfaces * chore: move nonce contants to nonce.ts * chore: fix MAX_NONCE
1 parent 5035116 commit 9b04145

File tree

5 files changed

+110
-48
lines changed

5 files changed

+110
-48
lines changed

benchmarks/nonce.js

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
/* eslint-disable */
2+
import benchmark from 'benchmark'
3+
import { Nonce } from '../dist/src/nonce.js'
4+
5+
/**
6+
* Using Nonce class is 150x faster than nonceToBytes
7+
* nonceToBytes x 2.25 ops/sec ±1.41% (10 runs sampled)
8+
* Nonce class x 341 ops/sec ±0.71% (87 runs sampled)
9+
*/
10+
function nonceToBytes (n) {
11+
// Even though we're treating the nonce as 8 bytes, RFC7539 specifies 12 bytes for a nonce.
12+
const nonce = new Uint8Array(12)
13+
new DataView(nonce.buffer, nonce.byteOffset, nonce.byteLength).setUint32(4, n, true)
14+
return nonce
15+
}
16+
const main = function () {
17+
const bench1 = new benchmark('nonceToBytes', {
18+
fn: function () {
19+
for (let i = 1e6; i < 2 * 1e6; i++) {
20+
nonceToBytes(i)
21+
}
22+
}
23+
})
24+
.on('complete', function (stats) {
25+
console.log(String(stats.currentTarget))
26+
})
27+
28+
bench1.run()
29+
30+
const bench2 = new benchmark('Nonce class', {
31+
fn: function () {
32+
const nonce = new Nonce(1e6)
33+
for (let i = 1e6; i < 2 * 1e6; i++) {
34+
nonce.increment()
35+
}
36+
}
37+
})
38+
.on('complete', function (stats) {
39+
console.log(String(stats.currentTarget))
40+
})
41+
42+
bench2.run()
43+
}
44+
45+
main()

src/@types/handshake.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import type { bytes, bytes32, uint64 } from './basic.js'
22
import type { KeyPair } from './libp2p.js'
3+
import type { Nonce } from '../nonce.js'
34

45
export type Hkdf = [bytes, bytes, bytes]
56

@@ -11,9 +12,9 @@ export interface MessageBuffer {
1112

1213
export interface CipherState {
1314
k: bytes32
14-
// For performance reasons, the nonce is represented as a JS `number`
15+
// For performance reasons, the nonce is represented as a Nonce object
1516
// The nonce is treated as a uint64, even though the underlying `number` only has 52 safely-available bits.
16-
n: uint64
17+
n: Nonce
1718
}
1819

1920
export interface SymmetricState {

src/handshakes/abstract-handshake.ts

Lines changed: 11 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,11 @@
11
import { equals as uint8ArrayEquals } from 'uint8arrays/equals'
22
import { concat as uint8ArrayConcat } from 'uint8arrays/concat'
33
import { fromString as uint8ArrayFromString } from 'uint8arrays'
4-
import type { bytes, bytes32, uint64 } from '../@types/basic.js'
4+
import type { bytes, bytes32 } from '../@types/basic.js'
55
import type { CipherState, MessageBuffer, SymmetricState } from '../@types/handshake.js'
66
import type { ICryptoInterface } from '../crypto.js'
77
import { logger } from '../logger.js'
8-
9-
export const MIN_NONCE = 0
10-
// For performance reasons, the nonce is represented as a JS `number`
11-
// JS `number` can only safely represent integers up to 2 ** 53 - 1
12-
// This is a slight deviation from the noise spec, which describes the max nonce as 2 ** 64 - 2
13-
// The effect is that this implementation will need a new handshake to be performed after fewer messages are exchanged than other implementations with full uint64 nonces.
14-
// 2 ** 53 - 1 is still a large number of messages, so the practical effect of this is negligible.
15-
export const MAX_NONCE = Number.MAX_SAFE_INTEGER
16-
17-
const ERR_MAX_NONCE = 'Cipherstate has reached maximum n, a new handshake must be performed'
8+
import { Nonce } from '../nonce.js'
189

1910
export abstract class AbstractHandshake {
2011
public crypto: ICryptoInterface
@@ -25,14 +16,14 @@ export abstract class AbstractHandshake {
2516

2617
public encryptWithAd (cs: CipherState, ad: Uint8Array, plaintext: Uint8Array): bytes {
2718
const e = this.encrypt(cs.k, cs.n, ad, plaintext)
28-
this.setNonce(cs, this.incrementNonce(cs.n))
19+
cs.n.increment()
2920

3021
return e
3122
}
3223

3324
public decryptWithAd (cs: CipherState, ad: Uint8Array, ciphertext: Uint8Array): {plaintext: bytes, valid: boolean} {
3425
const { plaintext, valid } = this.decrypt(cs.k, cs.n, ad, ciphertext)
35-
this.setNonce(cs, this.incrementNonce(cs.n))
26+
cs.n.increment()
3627

3728
return { plaintext, valid }
3829
}
@@ -42,10 +33,6 @@ export abstract class AbstractHandshake {
4233
return !this.isEmptyKey(cs.k)
4334
}
4435

45-
protected setNonce (cs: CipherState, nonce: uint64): void {
46-
cs.n = nonce
47-
}
48-
4936
protected createEmptyKey (): bytes32 {
5037
return new Uint8Array(32)
5138
}
@@ -55,26 +42,10 @@ export abstract class AbstractHandshake {
5542
return uint8ArrayEquals(emptyKey, k)
5643
}
5744

58-
protected incrementNonce (n: uint64): uint64 {
59-
return n + 1
60-
}
61-
62-
protected nonceToBytes (n: uint64): bytes {
63-
// Even though we're treating the nonce as 8 bytes, RFC7539 specifies 12 bytes for a nonce.
64-
const nonce = new Uint8Array(12)
65-
new DataView(nonce.buffer, nonce.byteOffset, nonce.byteLength).setUint32(4, n, true)
45+
protected encrypt (k: bytes32, n: Nonce, ad: Uint8Array, plaintext: Uint8Array): bytes {
46+
n.assertValue()
6647

67-
return nonce
68-
}
69-
70-
protected encrypt (k: bytes32, n: uint64, ad: Uint8Array, plaintext: Uint8Array): bytes {
71-
if (n > MAX_NONCE) {
72-
throw new Error(ERR_MAX_NONCE)
73-
}
74-
75-
const nonce = this.nonceToBytes(n)
76-
77-
return this.crypto.chaCha20Poly1305Encrypt(plaintext, nonce, ad, k)
48+
return this.crypto.chaCha20Poly1305Encrypt(plaintext, n.getBytes(), ad, k)
7849
}
7950

8051
protected encryptAndHash (ss: SymmetricState, plaintext: bytes): bytes {
@@ -89,13 +60,10 @@ export abstract class AbstractHandshake {
8960
return ciphertext
9061
}
9162

92-
protected decrypt (k: bytes32, n: uint64, ad: bytes, ciphertext: bytes): {plaintext: bytes, valid: boolean} {
93-
if (n > MAX_NONCE) {
94-
throw new Error(ERR_MAX_NONCE)
95-
}
63+
protected decrypt (k: bytes32, n: Nonce, ad: bytes, ciphertext: bytes): {plaintext: bytes, valid: boolean} {
64+
n.assertValue()
9665

97-
const nonce = this.nonceToBytes(n)
98-
const encryptedMessage = this.crypto.chaCha20Poly1305Decrypt(ciphertext, nonce, ad, k)
66+
const encryptedMessage = this.crypto.chaCha20Poly1305Decrypt(ciphertext, n.getBytes(), ad, k)
9967

10068
if (encryptedMessage) {
10169
return {
@@ -154,8 +122,7 @@ export abstract class AbstractHandshake {
154122
}
155123

156124
protected initializeKey (k: bytes32): CipherState {
157-
const n = MIN_NONCE
158-
return { k, n }
125+
return { k, n: new Nonce() }
159126
}
160127

161128
// Symmetric state related

src/logger.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -43,8 +43,8 @@ export function logRemoteEphemeralKey (re: Uint8Array): void {
4343

4444
export function logCipherState (session: NoiseSession): void {
4545
if (session.cs1 && session.cs2) {
46-
keyLogger(`CIPHER_STATE_1 ${session.cs1.n} ${uint8ArrayToString(session.cs1.k, 'hex')}`)
47-
keyLogger(`CIPHER_STATE_2 ${session.cs2.n} ${uint8ArrayToString(session.cs2.k, 'hex')}`)
46+
keyLogger(`CIPHER_STATE_1 ${session.cs1.n.getUint64()} ${uint8ArrayToString(session.cs1.k, 'hex')}`)
47+
keyLogger(`CIPHER_STATE_2 ${session.cs2.n.getUint64()} ${uint8ArrayToString(session.cs2.k, 'hex')}`)
4848
} else {
4949
keyLogger('Missing cipher state.')
5050
}

src/nonce.ts

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
import type { bytes, uint64 } from './@types/basic'
2+
3+
export const MIN_NONCE = 0
4+
// For performance reasons, the nonce is represented as a JS `number`
5+
// Although JS `number` can safely represent integers up to 2 ** 53 - 1, we choose to only use
6+
// 4 bytes to store the data for performance reason.
7+
// This is a slight deviation from the noise spec, which describes the max nonce as 2 ** 64 - 2
8+
// The effect is that this implementation will need a new handshake to be performed after fewer messages are exchanged than other implementations with full uint64 nonces.
9+
// this MAX_NONCE is still a large number of messages, so the practical effect of this is negligible.
10+
export const MAX_NONCE = 0xffffffff
11+
12+
const ERR_MAX_NONCE = 'Cipherstate has reached maximum n, a new handshake must be performed'
13+
14+
/**
15+
* The nonce is an uint that's increased over time.
16+
* Maintaining different representations help improve performance.
17+
*/
18+
export class Nonce {
19+
private n: uint64
20+
private readonly bytes: bytes
21+
private readonly view: DataView
22+
23+
constructor (n = MIN_NONCE) {
24+
this.n = n
25+
this.bytes = new Uint8Array(12)
26+
this.view = new DataView(this.bytes.buffer, this.bytes.byteOffset, this.bytes.byteLength)
27+
this.view.setUint32(4, n, true)
28+
}
29+
30+
increment (): void {
31+
this.n++
32+
// Even though we're treating the nonce as 8 bytes, RFC7539 specifies 12 bytes for a nonce.
33+
this.view.setUint32(4, this.n, true)
34+
}
35+
36+
getBytes (): bytes {
37+
return this.bytes
38+
}
39+
40+
getUint64 (): uint64 {
41+
return this.n
42+
}
43+
44+
assertValue (): void {
45+
if (this.n > MAX_NONCE) {
46+
throw new Error(ERR_MAX_NONCE)
47+
}
48+
}
49+
}

0 commit comments

Comments
 (0)