diff --git a/src/handshake_state.ts b/src/handshake_state.ts index 8ba0fec..c1be0aa 100644 --- a/src/handshake_state.ts +++ b/src/handshake_state.ts @@ -393,7 +393,7 @@ export class HandshakeState { } // We add the ephemeral public key to the Waku payload - outHandshakeMessage.push(NoisePublicKey.to(this.e.publicKey)); + outHandshakeMessage.push(NoisePublicKey.fromPublicKey(this.e.publicKey)); } break; diff --git a/src/index.spec.ts b/src/index.spec.ts index 46e62d2..c260948 100644 --- a/src/index.spec.ts +++ b/src/index.spec.ts @@ -5,11 +5,11 @@ import { equals as uint8ArrayEquals } from "uint8arrays/equals"; import { chaCha20Poly1305Encrypt, dh, generateX25519KeyPair } from "./crypto"; import { Handshake, HandshakeStepResult } from "./handshake"; -import { CipherState, SymmetricState } from "./noise"; +import { CipherState, createEmptyKey, SymmetricState } from "./noise"; import { MAX_NONCE, Nonce } from "./nonce"; import { NoiseHandshakePatterns } from "./patterns"; import { MessageNametagBuffer } from "./payload"; -import { NoisePublicKey } from "./publickey"; +import { ChaChaPolyCipherState, NoisePublicKey } from "./publickey"; function randomCipherState(rng: HMACDRBG, nonce: number = 0): CipherState { const randomCipherState = new CipherState(); @@ -22,9 +22,103 @@ function c(input: Uint8Array): Uint8Array { return new Uint8Array(input); } +function randomChaChaPolyCipherState(rng: HMACDRBG): ChaChaPolyCipherState { + const k = rng.randomBytes(32); + const n = rng.randomBytes(16); + const ad = rng.randomBytes(32); + return new ChaChaPolyCipherState(k, n, ad); +} + +function randomNoisePublicKey(): NoisePublicKey { + const keypair = generateX25519KeyPair(); + return new NoisePublicKey(0, keypair.publicKey); +} + describe("js-noise", () => { const rng = new HMACDRBG(); + it("ChaChaPoly Encryption/Decryption: random byte sequences", function () { + const cipherState = randomChaChaPolyCipherState(rng); + + // We encrypt/decrypt random byte sequences + const plaintext = rng.randomBytes(128); + const ciphertext = cipherState.encrypt(plaintext); + const decrypted = cipherState.decrypt(ciphertext); + + expect(uint8ArrayEquals(decrypted, plaintext)).to.be.true; + }); + + it("ChaChaPoly Encryption/Decryption: random byte sequences", function () { + const cipherState = randomChaChaPolyCipherState(rng); + + // We encrypt/decrypt random byte sequences + const plaintext = rng.randomBytes(128); + const ciphertext = cipherState.encrypt(plaintext); + const decrypted = cipherState.decrypt(ciphertext); + + expect(uint8ArrayEquals(decrypted, plaintext)).to.be.true; + }); + + it("Noise public keys: encrypt and decrypt a public key", function () { + const noisePublicKey = randomNoisePublicKey(); + const cipherState = randomChaChaPolyCipherState(rng); + + const encryptedPK = NoisePublicKey.encrypt(noisePublicKey, cipherState); + const decryptedPK = NoisePublicKey.decrypt(encryptedPK, cipherState); + + expect(noisePublicKey.equals(decryptedPK)).to.be.true; + }); + + it("Noise public keys: decrypt an unencrypted public key", function () { + const noisePublicKey = randomNoisePublicKey(); + const cipherState = randomChaChaPolyCipherState(rng); + + const decryptedPK = NoisePublicKey.decrypt(noisePublicKey, cipherState); + + expect(noisePublicKey.equals(decryptedPK)).to.be.true; + }); + + it("Noise public keys: encrypt an encrypted public key", function () { + const noisePublicKey = randomNoisePublicKey(); + const cipherState = randomChaChaPolyCipherState(rng); + + const encryptedPK = NoisePublicKey.encrypt(noisePublicKey, cipherState); + const encryptedPK2 = NoisePublicKey.encrypt(encryptedPK, cipherState); + + expect(encryptedPK.equals(encryptedPK2)).to.be.true; + }); + + it("Noise public keys: encrypt, decrypt and decrypt a public key", function () { + const noisePublicKey = randomNoisePublicKey(); + const cipherState = randomChaChaPolyCipherState(rng); + + const encryptedPK = NoisePublicKey.encrypt(noisePublicKey, cipherState); + const decryptedPK = NoisePublicKey.decrypt(encryptedPK, cipherState); + const decryptedPK2 = NoisePublicKey.decrypt(decryptedPK, cipherState); + + expect(decryptedPK.equals(decryptedPK2)).to.be.true; + }); + + it("Noise public keys: serialize and deserialize an unencrypted public key", function () { + const noisePublicKey = randomNoisePublicKey(); + const serializedNoisePublicKey = noisePublicKey.serialize(); + const deserializedNoisePublicKey = NoisePublicKey.deserialize(serializedNoisePublicKey); + + expect(noisePublicKey.equals(deserializedNoisePublicKey)).to.be.true; + }); + + it("Noise public keys: encrypt, serialize, deserialize and decrypt a public key", function () { + const noisePublicKey = randomNoisePublicKey(); + const cipherState = randomChaChaPolyCipherState(rng); + + const encryptedPK = NoisePublicKey.encrypt(noisePublicKey, cipherState); + const serializedNoisePublicKey = encryptedPK.serialize(); + const deserializedNoisePublicKey = NoisePublicKey.deserialize(serializedNoisePublicKey); + const decryptedPK = NoisePublicKey.decrypt(deserializedNoisePublicKey, cipherState); + + expect(noisePublicKey.equals(decryptedPK)).to.be.true; + }); + it("Noise State Machine: Diffie-Hellman operation", function () { const aliceKey = generateX25519KeyPair(); const bobKey = generateX25519KeyPair(); @@ -65,7 +159,7 @@ describe("js-noise", () => { 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()); + cipherState.setCipherStateKey(createEmptyKey()); nonce = cipherState.getNonce(); nonceValue = nonce.getUint64(); plaintext = randomBytes(128, rng); @@ -75,7 +169,7 @@ describe("js-noise", () => { 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()); + cipherState.setCipherStateKey(createEmptyKey()); nonce = cipherState.getNonce(); nonceValue = nonce.getUint64(); ciphertext = randomBytes(128, rng); @@ -230,13 +324,13 @@ describe("js-noise", () => { // ========== // If at least one mixKey is executed (as above), ck is non-empty - expect(uint8ArrayEquals(symmetricState.getChainingKey(), CipherState.createEmptyKey())).to.be.false; + expect(uint8ArrayEquals(symmetricState.getChainingKey(), createEmptyKey())).to.be.false; // When a Symmetric State's ck is non-empty, we can execute split, which creates two distinct Cipher States cs1 and cs2 // with non-empty encryption keys and nonce set to 0 const { cs1, cs2 } = symmetricState.split(); - expect(uint8ArrayEquals(cs1.getKey(), CipherState.createEmptyKey())).to.be.false; - expect(uint8ArrayEquals(cs2.getKey(), CipherState.createEmptyKey())).to.be.false; + expect(uint8ArrayEquals(cs1.getKey(), createEmptyKey())).to.be.false; + expect(uint8ArrayEquals(cs2.getKey(), createEmptyKey())).to.be.false; expect(cs1.getNonce().getUint64()).to.be.equals(0); expect(cs2.getNonce().getUint64()).to.be.equals(0); expect(uint8ArrayEquals(cs1.getKey(), cs2.getKey())).to.be.false; @@ -453,7 +547,10 @@ describe("js-noise", () => { // <- s // ... // So we define accordingly the sequence of the pre-message public keys - const preMessagePKs = [NoisePublicKey.to(aliceStaticKey.publicKey), NoisePublicKey.to(bobStaticKey.publicKey)]; + const preMessagePKs = [ + NoisePublicKey.fromPublicKey(aliceStaticKey.publicKey), + NoisePublicKey.fromPublicKey(bobStaticKey.publicKey), + ]; const aliceHS = new Handshake({ hsPattern, staticKey: aliceStaticKey, preMessagePKs, initiator: true }); const bobHS = new Handshake({ hsPattern, staticKey: bobStaticKey, preMessagePKs }); @@ -546,7 +643,7 @@ describe("js-noise", () => { // <- s // ... // So we define accordingly the sequence of the pre-message public keys - const preMessagePKs = [NoisePublicKey.to(bobStaticKey.publicKey)]; + const preMessagePKs = [NoisePublicKey.fromPublicKey(bobStaticKey.publicKey)]; const aliceHS = new Handshake({ hsPattern, staticKey: aliceStaticKey, preMessagePKs, initiator: true }); const bobHS = new Handshake({ hsPattern, staticKey: bobStaticKey, preMessagePKs }); diff --git a/src/noise.ts b/src/noise.ts index 610c216..58443d5 100644 --- a/src/noise.ts +++ b/src/noise.ts @@ -35,6 +35,15 @@ import { HandshakePattern } from "./patterns.js"; ################################# */ +export function createEmptyKey(): bytes32 { + return new Uint8Array(32); +} + +export function isEmptyKey(k: bytes32): boolean { + const emptyKey = createEmptyKey(); + return uint8ArrayEquals(emptyKey, k); +} + // The Cipher State as in https://noiseprotocol.org/noise.html#the-cipherstate-object // Contains an encryption key k and a nonce n (used in Noise as a counter) export class CipherState { @@ -43,7 +52,7 @@ export class CipherState { // The nonce is treated as a uint64, even though the underlying `number` only has 52 safely-available bits. n: Nonce; - constructor(k: bytes32 = CipherState.createEmptyKey(), n = new Nonce()) { + constructor(k: bytes32 = createEmptyKey(), n = new Nonce()) { this.k = k; this.n = n; } @@ -58,16 +67,7 @@ export class CipherState { // Checks if a Cipher State has an encryption key set protected hasKey(): boolean { - return !this.isEmptyKey(this.k); - } - - static createEmptyKey(): bytes32 { - return new Uint8Array(32); - } - - protected isEmptyKey(k: bytes32): boolean { - const emptyKey = CipherState.createEmptyKey(); - return uint8ArrayEquals(emptyKey, k); + return !isEmptyKey(this.k); } // Encrypts a plaintext using key material in a Noise Cipher State diff --git a/src/publickey.ts b/src/publickey.ts index 94f449e..e95267e 100644 --- a/src/publickey.ts +++ b/src/publickey.ts @@ -1,6 +1,48 @@ +import { concat as uint8ArrayConcat } from "uint8arrays/concat"; import { equals as uint8ArrayEquals } from "uint8arrays/equals"; import { bytes32 } from "./@types/basic"; +import { chaCha20Poly1305Decrypt, chaCha20Poly1305Encrypt } from "./crypto"; +import { isEmptyKey } from "./noise"; + +// A ChaChaPoly Cipher State containing key (k), nonce (nonce) and associated data (ad) +export class ChaChaPolyCipherState { + k: bytes32; + nonce: bytes32; + ad: Uint8Array; + constructor(k: bytes32 = new Uint8Array(), nonce: bytes32 = new Uint8Array(), ad: Uint8Array = new Uint8Array()) { + this.k = k; + this.nonce = nonce; + this.ad = ad; + } + + // It takes a Cipher State (with key, nonce, and associated data) and encrypts a plaintext + // The cipher state in not changed + encrypt(plaintext: Uint8Array): Uint8Array { + // If plaintext is empty, we raise an error + if (plaintext.length == 0) { + throw new Error("tried to encrypt empty plaintext"); + } + + return chaCha20Poly1305Encrypt(plaintext, this.nonce, this.ad, this.k); + } + + // ChaChaPoly decryption + // It takes a Cipher State (with key, nonce, and associated data) and decrypts a ciphertext + // The cipher state is not changed + decrypt(ciphertext: Uint8Array): Uint8Array { + // If ciphertext is empty, we raise an error + if (ciphertext.length == 0) { + throw new Error("tried to decrypt empty ciphertext"); + } + const plaintext = chaCha20Poly1305Decrypt(ciphertext, this.nonce, this.ad, this.k); + if (!plaintext) { + throw new Error("decryptWithAd failed"); + } + + return plaintext; + } +} // A Noise public key is a public key exchanged during Noise handshakes (no private part) // This follows https://rfc.vac.dev/spec/35/#public-keys-serialization @@ -26,7 +68,58 @@ export class NoisePublicKey { } // Converts a public Elliptic Curve key to an unencrypted Noise public key - static to(publicKey: bytes32): NoisePublicKey { + static fromPublicKey(publicKey: bytes32): NoisePublicKey { return new NoisePublicKey(0, publicKey); } + + // Converts a Noise public key to a stream of bytes as in + // https://rfc.vac.dev/spec/35/#public-keys-serialization + serialize(): Uint8Array { + // Public key is serialized as (flag || pk) + // Note that pk contains the X coordinate of the public key if unencrypted + // or the encryption concatenated with the authorization tag if encrypted + const serializedNoisePublicKey = new Uint8Array(uint8ArrayConcat([new Uint8Array([this.flag ? 1 : 0]), this.pk])); + return serializedNoisePublicKey; + } + + // Converts a serialized Noise public key to a NoisePublicKey object as in + // https://rfc.vac.dev/spec/35/#public-keys-serialization + static deserialize(serializedPK: Uint8Array): NoisePublicKey { + if (serializedPK.length == 0) throw new Error("invalid serialized key"); + + // We retrieve the encryption flag + const flag = serializedPK[0]; + if (!(flag == 0 || flag == 1)) throw new Error("invalid flag in serialized public key"); + + const pk = serializedPK.subarray(1); + + return new NoisePublicKey(flag, pk); + } + + static encrypt(ns: NoisePublicKey, cs: ChaChaPolyCipherState): NoisePublicKey { + // We proceed with encryption only if + // - a key is set in the cipher state + // - the public key is unencrypted + if (!isEmptyKey(cs.k) && ns.flag == 0) { + const encPk = cs.encrypt(ns.pk); + return new NoisePublicKey(1, encPk); + } + + // Otherwise we return the public key as it is + return ns.clone(); + } + + // Decrypts a Noise public key using a ChaChaPoly Cipher State + static decrypt(ns: NoisePublicKey, cs: ChaChaPolyCipherState): NoisePublicKey { + // We proceed with decryption only if + // - a key is set in the cipher state + // - the public key is encrypted + if (!isEmptyKey(cs.k) && ns.flag == 1) { + const decrypted = cs.decrypt(ns.pk); + return new NoisePublicKey(0, decrypted); + } + + // Otherwise we return the public key as it is + return ns.clone(); + } }