From 11b97bafaf806ae08a3c60a108cccd74163b542c Mon Sep 17 00:00:00 2001 From: Richard Ramos Date: Mon, 14 Nov 2022 14:46:42 -0400 Subject: [PATCH] chore: setup test --- .cspell.json | 2 +- package-lock.json | 30 +++++++++- package.json | 4 +- src/handshake.ts | 6 +- src/handshake_state.ts | 2 +- src/index.spec.ts | 128 +++++++++++++++++++++++++++++++++++++++++ src/noise.ts | 22 ++++--- 7 files changed, 177 insertions(+), 17 deletions(-) create mode 100644 src/index.spec.ts diff --git a/.cspell.json b/.cspell.json index fe72f7b..3de0f9c 100644 --- a/.cspell.json +++ b/.cspell.json @@ -2,7 +2,7 @@ "version": "0.1", "$schema": "https://raw.githubusercontent.com/streetsidesoftware/cspell/master/cspell.schema.json", "language": "en", - "words": ["Waku", "keypair", "nwaku"], + "words": ["Waku", "keypair", "nwaku", "Nametag, ciphertext", "unpad", "blocksize", "Nametag", "Cipherstate", "Nametags", "HASHLEN", "ciphertext", "preshared", "libp2p"], "flagWords": [], "ignorePaths": [ "package.json", diff --git a/package-lock.json b/package-lock.json index 38488bd..ad56829 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,16 +1,18 @@ { - "name": "js-noise", + "name": "@waku/noise", "version": "0.0.1", "lockfileVersion": 2, "requires": true, "packages": { "": { - "name": "js-noise", + "name": "@waku/noise", "version": "0.0.1", "license": "Apache-2.0 OR MIT", "dependencies": { "@stablelib/chacha20poly1305": "^1.0.1", "@stablelib/hkdf": "^1.0.1", + "@stablelib/hmac-drbg": "^1.0.2", + "@stablelib/random": "^1.0.2", "@stablelib/sha256": "^1.0.1", "@stablelib/x25519": "^1.0.1", "pkcs7-padding": "^0.1.1", @@ -881,6 +883,18 @@ "@stablelib/wipe": "^1.0.1" } }, + "node_modules/@stablelib/hmac-drbg": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@stablelib/hmac-drbg/-/hmac-drbg-1.0.2.tgz", + "integrity": "sha512-lZRnTm9VpuP864X7wOFv1o3t9t1DS94A3EQym9a2Eg8iUfIvp17uPP6LJKlTbD/vpUvbEDBTDoZV0uqxgcOdGw==", + "dependencies": { + "@stablelib/hash": "^1.0.1", + "@stablelib/hmac": "^1.0.1", + "@stablelib/random": "^1.0.2", + "@stablelib/sha256": "^1.0.1", + "@stablelib/wipe": "^1.0.1" + } + }, "node_modules/@stablelib/int": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/@stablelib/int/-/int-1.0.1.tgz", @@ -9647,6 +9661,18 @@ "@stablelib/wipe": "^1.0.1" } }, + "@stablelib/hmac-drbg": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@stablelib/hmac-drbg/-/hmac-drbg-1.0.2.tgz", + "integrity": "sha512-lZRnTm9VpuP864X7wOFv1o3t9t1DS94A3EQym9a2Eg8iUfIvp17uPP6LJKlTbD/vpUvbEDBTDoZV0uqxgcOdGw==", + "requires": { + "@stablelib/hash": "^1.0.1", + "@stablelib/hmac": "^1.0.1", + "@stablelib/random": "^1.0.2", + "@stablelib/sha256": "^1.0.1", + "@stablelib/wipe": "^1.0.1" + } + }, "@stablelib/int": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/@stablelib/int/-/int-1.0.1.tgz", diff --git a/package.json b/package.json index 5d36948..fba11ca 100644 --- a/package.json +++ b/package.json @@ -22,8 +22,8 @@ "fix:lint": "eslint src --ext .ts --ext .cjs --fix", "test": "run-s test:*", "test:lint": "eslint src --ext .ts", - "test:prettier": "prettier \"src/**/*.ts\" \"./*.json\" \"*.*js\" \".github/**/*.yml\" --list-different", "test:spelling": "cspell \"{*.md,.github/*.md,src/**/*.ts}\"", + "test:prettier": "prettier \"src/**/*.ts\" \"*.*js\" \".github/**/*.yml\" --list-different", "test:tsc": "tsc -p tsconfig.dev.json", "test:browser": "karma start karma.conf.cjs", "watch:build": "tsc -p tsconfig.json -w", @@ -110,6 +110,8 @@ "dependencies": { "@stablelib/chacha20poly1305": "^1.0.1", "@stablelib/hkdf": "^1.0.1", + "@stablelib/hmac-drbg": "^1.0.2", + "@stablelib/random": "^1.0.2", "@stablelib/sha256": "^1.0.1", "@stablelib/x25519": "^1.0.1", "pkcs7-padding": "^0.1.1", diff --git a/src/handshake.ts b/src/handshake.ts index d42d423..71e6dfa 100644 --- a/src/handshake.ts +++ b/src/handshake.ts @@ -19,7 +19,7 @@ export class HandshakeStepResult { transportMessage: Uint8Array = new Uint8Array(); } -// When a handshake is complete, the HandhshakeResult will contain the two +// When a handshake is complete, the HandshakeResult will contain the two // Cipher States used to encrypt/decrypt outbound/inbound messages // The recipient static key rs and handshake hash values h are stored to address some possible future applications (channel-binding, session management, etc.). // However, are not required by Noise specifications and are thus optional @@ -59,7 +59,7 @@ export class Handshake { // Advances 1 step in handshake // Each user in a handshake alternates writing and reading of handshake messages. // If the user is writing the handshake message, the transport message (if not empty) and eventually a non-empty message nametag has to be passed to transportMessage and messageNametag and readPayloadV2 can be left to its default value - // It the user is reading the handshake message, the read payload v2 has to be passed to readPayloadV2 and the transportMessage can be left to its default values. Decryption is skipped if the payloadv2 read doesn't have a message nametag equal to messageNametag (empty input nametags are converted to all-0 MessageNametagLength bytes arrays) + // It the user is reading the handshake message, the read payload v2 has to be passed to readPayloadV2 and the transportMessage can be left to its default values. Decryption is skipped if the PayloadV2 read doesn't have a message nametag equal to messageNametag (empty input nametags are converted to all-0 MessageNametagLength bytes arrays) stepHandshake( readPayloadV2: PayloadV2 = new PayloadV2(), transportMessage: Uint8Array = new Uint8Array(), @@ -120,7 +120,7 @@ export class Handshake { const readHandshakeMessage = readPayloadV2.handshakeMessage; const readTransportMessage = readPayloadV2.transportMessage; - // Since we only read, nothing meanigful (i.e. public keys) is returned + // Since we only read, nothing meaningful (i.e. public keys) is returned this.hs.processMessagePatternTokens(readHandshakeMessage); // We retrieve and store the (decrypted) received transport message by passing the messageNametag as extra additional data hsStepResult.transportMessage = this.hs.processMessagePatternPayload( diff --git a/src/handshake_state.ts b/src/handshake_state.ts index 590a7c6..2c63da0 100644 --- a/src/handshake_state.ts +++ b/src/handshake_state.ts @@ -27,7 +27,7 @@ export const NoisePaddingBlockSize = 248; // - the local and remote ephemeral/static keys e,s,re,rs (if any) // - the initiator flag (true if the user creating the state is the handshake initiator, false otherwise) // - the handshakePattern (containing the handshake protocol name, and (pre)message patterns) -// This object is futher extended from specifications by storing: +// This object is further extended from specifications by storing: // - a message pattern index msgPatternIdx indicating the next handshake message pattern to process // - the user's preshared psk, if any export class HandshakeState { diff --git a/src/index.spec.ts b/src/index.spec.ts new file mode 100644 index 0000000..f2e4588 --- /dev/null +++ b/src/index.spec.ts @@ -0,0 +1,128 @@ +import { HMACDRBG } from "@stablelib/hmac-drbg"; +import { randomBytes } from "@stablelib/random"; +import { expect } from "chai"; +import { equals as uint8ArrayEquals } from "uint8arrays/equals"; + +import { chaCha20Poly1305Encrypt, dh, generateX25519KeyPair } from "./crypto"; +import { CipherState } from "./noise"; +import { MAX_NONCE, Nonce } from "./nonce"; + +function randomCipherState(rng: HMACDRBG, nonce: number = 0): CipherState { + const randomCipherState = new CipherState(); + randomCipherState.n = new Nonce(nonce); + randomCipherState.k = rng.randomBytes(32); + return randomCipherState; +} + +describe("js-noise", () => { + it("Noise State Machine: Diffie-Hellman operation", function () { + const aliceKey = generateX25519KeyPair(); + const bobKey = generateX25519KeyPair(); + + // A Diffie-Hellman operation between Alice's private key and Bob's public key must be equal to + // a Diffie-hellman operation between Alice's public key and Bob's private key + const dh1 = dh(aliceKey.privateKey, bobKey.publicKey); + const dh2 = dh(bobKey.privateKey, aliceKey.publicKey); + + expect(uint8ArrayEquals(dh1, dh2)).to.be.true; + }); + + it("Noise State Machine: Cipher State primitives", function () { + const rng = new HMACDRBG(); + + // We generate a random Cipher State, associated data ad and plaintext + let cipherState = randomCipherState(rng); + let nonceValue = Math.floor(Math.random() * MAX_NONCE); + const ad = randomBytes(128, rng); + let plaintext = randomBytes(128, rng); + let nonce = new Nonce(nonceValue); + + // We set the random nonce generated in the cipher state + cipherState.setNonce(nonce); + + // We perform encryption + let ciphertext = cipherState.encryptWithAd(ad, plaintext); + + // After any encryption/decryption operation, the Cipher State's nonce increases by 1 + expect(cipherState.getNonce().getUint64()).to.be.equals(nonceValue + 1); + + // We set the nonce back to its original value for decryption + cipherState.setNonce(new Nonce(nonceValue)); + + // We decrypt (using the original nonce) + const decrypted = cipherState.decryptWithAd(ad, ciphertext); + + // We check if encryption and decryption are correct and that nonce correctly increased after decryption + expect(cipherState.getNonce().getUint64()).to.be.equals(nonceValue + 1); + expect(uint8ArrayEquals(plaintext, decrypted)).to.be.true; + + // If a Cipher State has no key set, encryptWithAd should return the plaintext without increasing the nonce + cipherState.setCipherStateKey(CipherState.createEmptyKey()); + nonce = cipherState.getNonce(); + nonceValue = nonce.getUint64(); + plaintext = randomBytes(128, rng); + ciphertext = cipherState.encryptWithAd(ad, plaintext); + + expect(uint8ArrayEquals(ciphertext, plaintext)).to.be.true; + expect(cipherState.getNonce().getUint64()).to.be.equals(nonceValue); + + // If a Cipher State has no key set, decryptWithAd should return the ciphertext without increasing the nonce + cipherState.setCipherStateKey(CipherState.createEmptyKey()); + nonce = cipherState.getNonce(); + nonceValue = nonce.getUint64(); + ciphertext = randomBytes(128, rng); + plaintext = cipherState.decryptWithAd(ad, ciphertext); + + expect(uint8ArrayEquals(ciphertext, plaintext)).to.be.true; + expect(cipherState.getNonce().getUint64()).to.be.equals(nonceValue); + + // A Cipher State cannot have a nonce greater or equal 0xffffffff in this implementation (see nonce.ts for details) + // Note that nonce is increased after each encryption and decryption operation + + // We generate a test Cipher State with nonce set to MaxNonce + cipherState = randomCipherState(rng); + cipherState.setNonce(new Nonce(MAX_NONCE)); + plaintext = randomBytes(128, rng); + + // We test if encryption fails. Any subsequent encryption call over the Cipher State should fail similarly and leave the nonce unchanged + for (let i = 0; i < 5; i++) { + try { + ciphertext = cipherState.encryptWithAd(ad, plaintext); + expect(true).to.be.false; // Should not reach this line + } catch (err) { + // Do nothing + } + expect(cipherState.getNonce().getUint64()).to.be.equals(MAX_NONCE + 1); + } + + // We generate a test Cipher State + // Since nonce is increased after decryption as well, we need to generate a proper ciphertext in order to test MaxNonceError error handling + // We cannot call encryptWithAd to encrypt a plaintext using a nonce equal MaxNonce, since this will trigger a MaxNonceError. + // To perform such test, we then need to encrypt a test plaintext using directly ChaChaPoly primitive + cipherState = randomCipherState(rng); + cipherState.setNonce(new Nonce(MAX_NONCE)); + plaintext = randomBytes(128, rng); + + // We perform encryption using the Cipher State key, NonceMax and ad + ciphertext = chaCha20Poly1305Encrypt( + plaintext, + cipherState.getNonce().getBytes(), + ad, + cipherState.getKey() + ); + + // At this point ciphertext is a proper encryption of the original plaintext obtained with nonce equal to NonceMax + // We can now test if decryption fails with a NoiseNonceMaxError error. Any subsequent decryption call over the Cipher State should fail similarly and leave the nonce unchanged + // Note that decryptWithAd doesn't fail in decrypting the ciphertext (otherwise a NoiseDecryptTagError would have been triggered) + for (let i = 0; i < 5; i++) { + try { + plaintext = cipherState.decryptWithAd(ad, ciphertext); + expect(true).to.be.false; // Should not reach this line + } catch (err) { + // Do nothing + } + + expect(cipherState.getNonce().getUint64()).to.be.equals(MAX_NONCE + 1); + } + }); +}); diff --git a/src/noise.ts b/src/noise.ts index aeed40c..2368129 100644 --- a/src/noise.ts +++ b/src/noise.ts @@ -30,7 +30,7 @@ import { HandshakePattern } from "./patterns.js"; # - A preshared key psk is processed by calling MixKeyAndHash(psk); # - When an ephemeral public key e is read or written, the handshake hash value h is updated by calling mixHash(e); If the handshake expects a psk, MixKey(e) is further called # - When an encrypted static public key s or a payload message m is read, it is decrypted with decryptAndHash; -# - When a static public key s or a payload message is writted, it is encrypted with encryptAndHash; +# - When a static public key s or a payload message is written, it is encrypted with encryptAndHash; # - When any Diffie-Hellman token ee, es, se, ss is read or written, the chaining key ck is updated by calling MixKey on the computed secret; # - If all tokens are processed, users compute two new Cipher States by calling Split; # - The two Cipher States obtained from Split are used to encrypt/decrypt outbound/inbound messages. @@ -113,6 +113,10 @@ export class CipherState { if (!plaintext) { throw "decryptWithAd failed"; } + + this.n.increment(); + this.n.assertValue(); + return plaintext; } else { // Otherwise we return the input ciphertext according to specification @@ -198,14 +202,14 @@ export class SymmetricState { // Combines MixKey and MixHash mixKeyAndHash(inputKeyMaterial: Uint8Array): void { // Derives 3 keys using HKDF, the chaining key and the input key material - const [tempk0, tempk1, tempk2] = getHKDF(this.ck, inputKeyMaterial); + const [tmpKey0, tmpKey1, tmpKey2] = getHKDF(this.ck, inputKeyMaterial); // Sets the chaining key - this.ck = tempk0; + this.ck = tmpKey0; // Updates the handshake hash value - this.mixHash(tempk1); + this.mixHash(tmpKey1); // Updates the Cipher state's key // Note for later support of 512 bits hash functions: "If HASHLEN is 64, then truncates tempKeys[2] to 32 bytes." - this.cs = new CipherState(tempk2); + this.cs = new CipherState(tmpKey2); } // EncryptAndHash as per Noise specification http://www.noiseprotocol.org/noise.html#the-symmetricstate-object @@ -217,7 +221,7 @@ export class SymmetricState { ): Uint8Array { // The additional data const ad = uint8ArrayConcat([this.h, extraAd]); - // Note that if an encryption key is not set yet in the Cipher state, ciphertext will be equal to plaintex + // Note that if an encryption key is not set yet in the Cipher state, ciphertext will be equal to plaintext const ciphertext = this.cs.encryptWithAd(ad, plaintext); // We call mixHash over the result this.mixHash(ciphertext); @@ -245,11 +249,11 @@ export class SymmetricState { // Once a handshake is complete, returns two Cipher States to encrypt/decrypt outbound/inbound messages split(): { cs1: CipherState; cs2: CipherState } { // Derives 2 keys using HKDF and the chaining key - const [tempk1, tempk2] = getHKDF(this.ck, new Uint8Array(0)); + const [tmpKey1, tmpKey2] = getHKDF(this.ck, new Uint8Array(0)); // Returns a tuple of two Cipher States initialized with the derived keys return { - cs1: new CipherState(tempk1), - cs2: new CipherState(tempk2), + cs1: new CipherState(tmpKey1), + cs2: new CipherState(tmpKey2), }; }