diff --git a/.cspell.json b/.cspell.json index 6223d3a..12483bc 100644 --- a/.cspell.json +++ b/.cspell.json @@ -14,6 +14,7 @@ "blocksize", "Nametag", "Cipherstate", + "cipherstates", "Nametags", "HASHLEN", "ciphertext", diff --git a/README.md b/README.md new file mode 100644 index 0000000..5288e34 --- /dev/null +++ b/README.md @@ -0,0 +1,49 @@ +# js-noise + +Browser library using Noise Protocols for Waku Payload Encryption +https://rfc.vac.dev/spec/35/ + +### Install + +``` +npm install @waku/noise + +# or with yarn + +yarn add @waku/noise +``` + +### Documentation +Refer to the specs and examples for details on how to use this library + + +### Running example app + +``` +git clone https://github.com/waku-org/js-noise +cd js-noise/example +npm install # or yarn +npm start + +``` + +Browse http://localhost:8080 to see the webapp where the pairing process can be initiated + + +## Bugs, Questions & Features + +If you encounter any bug or would like to propose new features, feel free to [open an issue](https://github.com/waku-org/js-rln/issues/new/). + +For more general discussion, help and latest news, join [Vac Discord](https://discord.gg/PQFdubGt6d) or [Telegram](https://t.me/vacp2p). + +## License + +Licensed and distributed under either of + +- MIT license: [LICENSE-MIT](LICENSE-MIT) or http://opensource.org/licenses/MIT + +or + +- Apache License, Version 2.0, ([LICENSE-APACHEv2](LICENSE-APACHEv2) or http://www.apache.org/licenses/LICENSE-2.0) + +at your option. These files may not be copied, modified, or distributed except according to those terms. diff --git a/example/index.js b/example/index.js index 8a64e20..fa397af 100644 --- a/example/index.js +++ b/example/index.js @@ -82,7 +82,7 @@ function getSenderAndResponder(node) { await subscriptions.get(contentTopic)(); subscriptions.delete(contentTopic); } else { - console.log("Subscriptipon doesnt exist") + console.log("Subscription doesnt exist"); } }, }; diff --git a/src/@types/basic.ts b/src/@types/basic.ts index e10f5f1..17fb92b 100644 --- a/src/@types/basic.ts +++ b/src/@types/basic.ts @@ -1,4 +1,3 @@ -export type bytes = Uint8Array; export type bytes32 = Uint8Array; export type bytes16 = Uint8Array; diff --git a/src/@types/handshake.ts b/src/@types/handshake.ts index d61da9b..5c6ebfd 100644 --- a/src/@types/handshake.ts +++ b/src/@types/handshake.ts @@ -1,6 +1,2 @@ -import type { bytes } from "./basic.js"; - -export type Hkdf = [bytes, bytes, bytes]; - // a transport message (for Noise handshakes and ChaChaPoly encryptions) export type MessageNametag = Uint8Array; diff --git a/src/codec.ts b/src/codec.ts index 5c94809..6485290 100644 --- a/src/codec.ts +++ b/src/codec.ts @@ -10,9 +10,28 @@ const log = debug("waku:message:noise-codec"); const OneMillion = BigInt(1_000_000); -export const Version = 2; +// WakuMessage version for noise protocol +const version = 2; +/** + * Used internally in the pairing object to represent a handshake message + */ +export class NoiseHandshakeMessage extends MessageV0 implements Message { + get payloadV2(): PayloadV2 { + if (!this.payload) throw new Error("no payload available"); + return PayloadV2.deserialize(this.payload); + } +} + +/** + * Used in the pairing object for encoding the messages exchanged + * during the handshake process + */ export class NoiseHandshakeEncoder implements Encoder { + /** + * @param contentTopic content topic on which the encoded WakuMessages will be sent + * @param hsStepResult the result of a step executed while performing the handshake process + */ constructor(public contentTopic: string, private hsStepResult: HandshakeStepResult) {} async encode(message: Message): Promise { @@ -25,34 +44,21 @@ export class NoiseHandshakeEncoder implements Encoder { const timestamp = message.timestamp ?? new Date(); return { payload: this.hsStepResult.payload2.serialize(), - version: Version, + version: version, contentTopic: this.contentTopic, timestamp: BigInt(timestamp.valueOf()) * OneMillion, }; } } -export class NoiseHandshakeMessage extends MessageV0 implements Message { - get payloadV2(): PayloadV2 { - if (!this.payload) throw new Error("no payload available"); - return PayloadV2.deserialize(this.payload); - } -} - -export class NoiseSecureMessage extends MessageV0 implements Message { - private readonly _decodedPayload: Uint8Array; - - constructor(proto: proto_message.WakuMessage, decodedPayload: Uint8Array) { - super(proto); - this._decodedPayload = decodedPayload; - } - - get payload(): Uint8Array { - return this._decodedPayload; - } -} - +/** + * Used in the pairing object for decoding the messages exchanged + * during the handshake process + */ export class NoiseHandshakeDecoder implements Decoder { + /** + * @param contentTopic content topic on which the encoded WakuMessages were sent + */ constructor(public contentTopic: string) {} decodeProto(bytes: Uint8Array): Promise { @@ -67,8 +73,8 @@ export class NoiseHandshakeDecoder implements Decoder { proto.version = 0; } - if (proto.version !== Version) { - log("Failed to decode due to incorrect version, expected:", Version, ", actual:", proto.version); + if (proto.version !== version) { + log("Failed to decode due to incorrect version, expected:", version, ", actual:", proto.version); return Promise.resolve(undefined); } @@ -81,7 +87,34 @@ export class NoiseHandshakeDecoder implements Decoder { } } +/** + * Represents a secure message. These are messages that are transmitted + * after a successful handshake is performed. + */ +export class NoiseSecureMessage extends MessageV0 implements Message { + private readonly _decodedPayload: Uint8Array; + + constructor(proto: proto_message.WakuMessage, decodedPayload: Uint8Array) { + super(proto); + this._decodedPayload = decodedPayload; + } + + get payload(): Uint8Array { + return this._decodedPayload; + } +} + +/** + * js-waku encoder for secure messages. After a handshake is successful, a + * codec for encoding messages is generated. The messages encoded with this + * codec will be encrypted with the cipherstates and message nametags that were + * created after a handshake is complete + */ export class NoiseSecureTransferEncoder implements Encoder { + /** + * @param contentTopic content topic on which the encoded WakuMessages were sent + * @param hsResult handshake result obtained after the handshake is successful + */ constructor(public contentTopic: string, private hsResult: HandshakeResult) {} async encode(message: Message): Promise { @@ -103,14 +136,24 @@ export class NoiseSecureTransferEncoder implements Encoder { return { payload, - version: Version, + version: version, contentTopic: this.contentTopic, timestamp: BigInt(timestamp.valueOf()) * OneMillion, }; } } +/** + * js-waku decoder for secure messages. After a handshake is successful, a codec + * for decoding messages is generated. This decoder will attempt to decrypt + * messages with the cipherstates and message nametags that were created after a + * handshake is complete + */ export class NoiseSecureTransferDecoder implements Decoder { + /** + * @param contentTopic content topic on which the encoded WakuMessages were sent + * @param hsResult handshake result obtained after the handshake is successful + */ constructor(public contentTopic: string, private hsResult: HandshakeResult) {} decodeProto(bytes: Uint8Array): Promise { @@ -125,8 +168,8 @@ export class NoiseSecureTransferDecoder implements Decoder { proto.version = 0; } - if (proto.version !== Version) { - log("Failed to decode due to incorrect version, expected:", Version, ", actual:", proto.version); + if (proto.version !== version) { + log("Failed to decode due to incorrect version, expected:", version, ", actual:", proto.version); return Promise.resolve(undefined); } diff --git a/src/crypto.ts b/src/crypto.ts index 5183674..cd7fc28 100644 --- a/src/crypto.ts +++ b/src/crypto.ts @@ -1,20 +1,32 @@ import { ChaCha20Poly1305, TAG_LENGTH } from "@stablelib/chacha20poly1305"; -import { HKDF } from "@stablelib/hkdf"; +import { HKDF as hkdf } from "@stablelib/hkdf"; import { hash, SHA256 } from "@stablelib/sha256"; import * as x25519 from "@stablelib/x25519"; import { concat as uint8ArrayConcat } from "uint8arrays/concat"; -import type { bytes, bytes32 } from "./@types/basic.js"; -import type { Hkdf } from "./@types/handshake.js"; +import type { bytes32 } from "./@types/basic.js"; import type { KeyPair } from "./@types/keypair.js"; export const Curve25519KeySize = x25519.PUBLIC_KEY_LENGTH; + export const ChachaPolyTagLen = TAG_LENGTH; +/** + * Generate hash using SHA2-256 + * @param data data to hash + * @returns hash digest + */ export function hashSHA256(data: Uint8Array): Uint8Array { return hash(data); } +/** + * Convert an Uint8Array into a 32-byte value. If the input data length is different + * from 32, throw an error. This is used mostly as a validation function to ensure + * that an Uint8Array represents a valid x25519 key + * @param s input data + * @return 32-byte key + */ export function intoCurve25519Key(s: Uint8Array): bytes32 { if (s.length != x25519.PUBLIC_KEY_LENGTH) { throw new Error("invalid public key length"); @@ -23,20 +35,29 @@ export function intoCurve25519Key(s: Uint8Array): bytes32 { return s; } -export function getHKDF(ck: bytes32, ikm: Uint8Array): Hkdf { - const okm = getHKDFRaw(ck, ikm, 96); - const k1 = okm.subarray(0, 32); - const k2 = okm.subarray(32, 64); - const k3 = okm.subarray(64, 96); - - return [k1, k2, k3]; -} - -export function getHKDFRaw(ck: bytes32, ikm: Uint8Array, numBytes: number): Uint8Array { - const hkdf = new HKDF(SHA256, ikm, ck); - return hkdf.expand(numBytes); +/** + * HKDF key derivation function using SHA256 + * @param ck chaining key + * @param ikm input key material + * @param length length of each generated key + * @param numKeys number of keys to generate + * @returns array of `numValues` length containing Uint8Array keys of a given byte `length` + */ +export function HKDF(ck: bytes32, ikm: Uint8Array, length: number, numKeys: number): Array { + const numBytes = length * numKeys; + const okm = new hkdf(SHA256, ikm, ck).expand(numBytes); + const result = []; + for (let i = 0; i < numBytes; i += length) { + const k = okm.subarray(i, i + length); + result.push(k); + } + return result; } +/** + * Generate a random keypair + * @returns Keypair + */ export function generateX25519KeyPair(): KeyPair { const keypair = x25519.generateKeyPair(); @@ -46,7 +67,12 @@ export function generateX25519KeyPair(): KeyPair { }; } -export function generateX25519KeyPairFromSeed(seed: Uint8Array): KeyPair { +/** + * Generate x25519 keypair using an input seed + * @param seed 32-byte secret + * @returns Keypair + */ +export function generateX25519KeyPairFromSeed(seed: bytes32): KeyPair { const keypair = x25519.generateKeyPairFromSeed(seed); return { @@ -55,30 +81,52 @@ export function generateX25519KeyPairFromSeed(seed: Uint8Array): KeyPair { }; } -export function generateX25519SharedKey(privateKey: Uint8Array, publicKey: Uint8Array): Uint8Array { - return x25519.sharedKey(privateKey, publicKey); -} - -export function chaCha20Poly1305Encrypt(plaintext: Uint8Array, nonce: Uint8Array, ad: Uint8Array, k: bytes32): bytes { +/** + * Encrypt and authenticate data using ChaCha20-Poly1305 + * @param plaintext data to encrypt + * @param nonce 12 byte little-endian nonce + * @param ad associated data + * @param k 32-byte key + * @returns sealed ciphertext including authentication tag + */ +export function chaCha20Poly1305Encrypt( + plaintext: Uint8Array, + nonce: Uint8Array, + ad: Uint8Array, + k: bytes32 +): Uint8Array { const ctx = new ChaCha20Poly1305(k); - return ctx.seal(nonce, plaintext, ad); } +/** + * Authenticate and decrypt data using ChaCha20-Poly1305 + * @param ciphertext data to decrypt + * @param nonce 12 byte little-endian nonce + * @param ad associated data + * @param k 32-byte key + * @returns plaintext if decryption was successful, `null` otherwise + */ export function chaCha20Poly1305Decrypt( ciphertext: Uint8Array, nonce: Uint8Array, ad: Uint8Array, k: bytes32 -): bytes | null { +): Uint8Array | null { const ctx = new ChaCha20Poly1305(k); return ctx.open(nonce, ciphertext, ad); } +/** + * Perform a Diffie–Hellman key exchange + * @param privateKey x25519 private key + * @param publicKey x25519 public key + * @returns shared secret + */ export function dh(privateKey: bytes32, publicKey: bytes32): bytes32 { try { - const derivedU8 = generateX25519SharedKey(privateKey, publicKey); + const derivedU8 = x25519.sharedKey(privateKey, publicKey); if (derivedU8.length === 32) { return derivedU8; @@ -91,7 +139,12 @@ export function dh(privateKey: bytes32, publicKey: bytes32): bytes32 { } } -// Commits a public key pk for randomness r as H(pk || s) +/** + * Generates a random static key commitment using a public key pk for randomness r as H(pk || s) + * @param publicKey x25519 public key + * @param r random fixed-length value + * @returns 32 byte hash + */ export function commitPublicKey(publicKey: bytes32, r: Uint8Array): bytes32 { return hashSHA256(uint8ArrayConcat([publicKey, r])); } diff --git a/src/handshake.ts b/src/handshake.ts index 7a0f5be..e794b1e 100644 --- a/src/handshake.ts +++ b/src/handshake.ts @@ -4,11 +4,12 @@ import { equals as uint8ArrayEquals } from "uint8arrays/equals"; import { bytes32 } from "./@types/basic"; import { KeyPair } from "./@types/keypair"; -import { getHKDFRaw } from "./crypto.js"; +import { HKDF } from "./crypto"; import { HandshakeState, NoisePaddingBlockSize } from "./handshake_state.js"; +import { MessageNametagBuffer, toMessageNametag } from "./messagenametag"; import { CipherState } from "./noise.js"; import { HandshakePattern, PayloadV2ProtocolIDs } from "./patterns.js"; -import { MessageNametagBuffer, PayloadV2, toMessageNametag } from "./payload.js"; +import { PayloadV2 } from "./payload.js"; import { NoisePublicKey } from "./publickey.js"; // Noise state machine @@ -172,7 +173,7 @@ export class Handshake { // Generates an 8 decimal digits authorization code using HKDF and the handshake state genAuthcode(): string { - const output0 = getHKDFRaw(this.hs.ss.h, new Uint8Array(), 8); + const [output0] = HKDF(this.hs.ss.h, new Uint8Array(), 8, 1); const bn = new BN(output0); const code = bn.mod(new BN(100_000_000)).toString().padStart(8, "0"); return code.toString(); diff --git a/src/handshake_state.ts b/src/handshake_state.ts index 8830db4..f3aecdb 100644 --- a/src/handshake_state.ts +++ b/src/handshake_state.ts @@ -5,10 +5,10 @@ import { equals as uint8ArrayEquals } from "uint8arrays/equals"; import { bytes32 } from "./@types/basic.js"; import { MessageNametag } from "./@types/handshake.js"; import type { KeyPair } from "./@types/keypair.js"; -import { Curve25519KeySize, dh, generateX25519KeyPair, getHKDF, intoCurve25519Key } from "./crypto.js"; +import { Curve25519KeySize, dh, generateX25519KeyPair, HKDF, intoCurve25519Key } from "./crypto.js"; +import { MessageNametagLength } from "./messagenametag"; import { SymmetricState } from "./noise.js"; -import { EmptyPreMessage, HandshakePattern, MessageDirection, NoiseTokens, PreMessagePattern } from "./patterns.js"; -import { MessageNametagLength } from "./payload.js"; +import { HandshakePattern, MessageDirection, NoiseTokens, PreMessagePattern } from "./patterns.js"; import { NoisePublicKey } from "./publickey.js"; const log = debug("waku:noise:handshake-state"); @@ -101,14 +101,15 @@ export class HandshakeState { } genMessageNametagSecrets(): { nms1: Uint8Array; nms2: Uint8Array } { - const [nms1, nms2] = getHKDF(this.ss.h, new Uint8Array()); + const [nms1, nms2] = HKDF(this.ss.h, new Uint8Array(), 2, 32); return { nms1, nms2 }; } // Uses the cryptographic information stored in the input handshake state to generate a random message nametag // In current implementation the messageNametag = HKDF(handshake hash value), but other derivation mechanisms can be implemented toMessageNametag(): MessageNametag { - const [output] = getHKDF(this.ss.h, new Uint8Array()); + console.log("HELLO!"); + const [output] = HKDF(this.ss.h, new Uint8Array(), 32, 1); return output.subarray(0, MessageNametagLength); } @@ -181,7 +182,7 @@ export class HandshakeState { // We retrieve the pre-message patterns to process, if any // If none, there's nothing to do - if (this.handshakePattern.preMessagePatterns == EmptyPreMessage) { + if (this.handshakePattern.preMessagePatterns.length == 0) { return; } diff --git a/src/index.spec.ts b/src/index.spec.ts index df8024c..9d1901f 100644 --- a/src/index.spec.ts +++ b/src/index.spec.ts @@ -5,10 +5,11 @@ import { equals as uint8ArrayEquals } from "uint8arrays/equals"; import { chaCha20Poly1305Encrypt, dh, generateX25519KeyPair } from "./crypto"; import { Handshake, HandshakeStepResult } from "./handshake"; +import { MessageNametagBuffer, MessageNametagLength } from "./messagenametag"; import { CipherState, createEmptyKey, SymmetricState } from "./noise"; import { MAX_NONCE, Nonce } from "./nonce"; import { NoiseHandshakePatterns } from "./patterns"; -import { MessageNametagBuffer, MessageNametagLength, PayloadV2 } from "./payload"; +import { PayloadV2 } from "./payload"; import { ChaChaPolyCipherState, NoisePublicKey } from "./publickey"; function randomCipherState(rng: HMACDRBG, nonce: number = 0): CipherState { diff --git a/src/index.ts b/src/index.ts index 9c8b3d2..b071d11 100644 --- a/src/index.ts +++ b/src/index.ts @@ -13,9 +13,9 @@ import { MessageNametagError, StepHandshakeParameters, } from "./handshake.js"; +import { MessageNametagBuffer } from "./messagenametag.js"; import { InitiatorParameters, Responder, ResponderParameters, Sender, WakuPairing } from "./pairing.js"; import { - EmptyPreMessage, HandshakePattern, MessageDirection, MessagePattern, @@ -24,7 +24,6 @@ import { PayloadV2ProtocolIDs, PreMessagePattern, } from "./patterns.js"; -import { MessageNametagBuffer } from "./payload.js"; import { ChaChaPolyCipherState, NoisePublicKey } from "./publickey.js"; import { QR } from "./qr.js"; @@ -38,7 +37,6 @@ export { }; export { generateX25519KeyPair, generateX25519KeyPairFromSeed }; export { - EmptyPreMessage, HandshakePattern, MessageDirection, MessagePattern, diff --git a/src/messagenametag.ts b/src/messagenametag.ts new file mode 100644 index 0000000..ee3ce46 --- /dev/null +++ b/src/messagenametag.ts @@ -0,0 +1,126 @@ +import { concat as uint8ArrayConcat } from "uint8arrays/concat"; +import { equals as uint8ArrayEquals } from "uint8arrays/equals"; + +import { MessageNametag } from "./@types/handshake.js"; +import { hashSHA256 } from "./crypto.js"; +import { writeUIntLE } from "./utils.js"; + +export const MessageNametagLength = 16; + +export const MessageNametagBufferSize = 50; + +/** + * Converts a sequence or array (arbitrary size) to a MessageNametag + * @param input + * @returns + */ +export function toMessageNametag(input: Uint8Array): MessageNametag { + return input.subarray(0, MessageNametagLength); +} + +export class MessageNametagBuffer { + private buffer: Array = new Array(MessageNametagBufferSize); + private counter = 0; + secret?: Uint8Array; + + constructor() { + for (let i = 0; i < this.buffer.length; i++) { + this.buffer[i] = new Uint8Array(MessageNametagLength); + } + } + + /** + * Initializes the empty Message nametag buffer. The n-th nametag is equal to HKDF( secret || n ) + */ + initNametagsBuffer(): void { + // We default the counter and buffer fields + this.counter = 0; + this.buffer = new Array(MessageNametagBufferSize); + + if (this.secret) { + for (let i = 0; i < this.buffer.length; i++) { + const counterBytesLE = writeUIntLE(new Uint8Array(8), this.counter, 0, 8); + const d = hashSHA256(uint8ArrayConcat([this.secret, counterBytesLE])); + this.buffer[i] = toMessageNametag(d); + this.counter++; + } + } else { + // We warn users if no secret is set + console.debug("The message nametags buffer has not a secret set"); + } + } + + /** + * Pop the nametag from the message nametag buffer + * @returns MessageNametag + */ + pop(): MessageNametag { + // Note that if the input MessageNametagBuffer is set to default, an all 0 messageNametag is returned + const messageNametag = new Uint8Array(this.buffer[0]); + this.delete(1); + return messageNametag; + } + + /** + * Checks if the input messageNametag is contained in the input MessageNametagBuffer + * @param messageNametag Message nametag to verify + * @returns true if it's the expected nametag, false otherwise + */ + checkNametag(messageNametag: MessageNametag): boolean { + const index = this.buffer.findIndex((x) => uint8ArrayEquals(x, messageNametag)); + + if (index == -1) { + console.debug("Message nametag not found in buffer"); + return false; + } else if (index > 0) { + console.debug( + "Message nametag is present in buffer but is not the next expected nametag. One or more messages were probably lost" + ); + return false; + } + + // index is 0, hence the read message tag is the next expected one + return true; + } + + private rotateLeft(k: number): void { + if (k < 0 || this.buffer.length == 0) { + return; + } + const idx = this.buffer.length - (k % this.buffer.length); + const a1 = this.buffer.slice(idx); + const a2 = this.buffer.slice(0, idx); + this.buffer = a1.concat(a2); + } + + /** + * Deletes the first n elements in buffer and appends n new ones + * @param n number of message nametags to delete + */ + delete(n: number): void { + if (n <= 0) { + return; + } + + // We ensure n is at most MessageNametagBufferSize (the buffer will be fully replaced) + n = Math.min(n, MessageNametagBufferSize); + + // We update the last n values in the array if a secret is set + // Note that if the input MessageNametagBuffer is set to default, nothing is done here + if (this.secret) { + // We rotate left the array by n + this.rotateLeft(n); + + for (let i = 0; i < n; i++) { + const counterBytesLE = writeUIntLE(new Uint8Array(8), this.counter, 0, 8); + const d = hashSHA256(uint8ArrayConcat([this.secret, counterBytesLE])); + + this.buffer[this.buffer.length - n + i] = toMessageNametag(d); + this.counter++; + } + } else { + // We warn users that no secret is set + console.debug("The message nametags buffer has no secret set"); + } + } +} diff --git a/src/noise.ts b/src/noise.ts index a9aa2f7..9be9e08 100644 --- a/src/noise.ts +++ b/src/noise.ts @@ -4,7 +4,7 @@ import { concat as uint8ArrayConcat } from "uint8arrays/concat"; import { equals as uint8ArrayEquals } from "uint8arrays/equals"; import type { bytes32 } from "./@types/basic.js"; -import { chaCha20Poly1305Decrypt, chaCha20Poly1305Encrypt, getHKDF, hashSHA256 } from "./crypto.js"; +import { chaCha20Poly1305Decrypt, chaCha20Poly1305Encrypt, hashSHA256, HKDF } from "./crypto.js"; import { Nonce } from "./nonce.js"; import { HandshakePattern } from "./patterns.js"; @@ -38,43 +38,74 @@ const log = debug("waku:noise:handshake-state"); ################################# */ +/** + * Create empty chaining key + * @returns 32-byte empty key + */ export function createEmptyKey(): bytes32 { return new Uint8Array(32); } +/** + * Checks if a 32-byte key is empty + * @param k key to verify + * @returns true if empty, false otherwise + */ 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) +/** + * 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 { k: bytes32; // For performance reasons, the nonce is represented as a Nonce object // The nonce is treated as a uint64, even though the underlying `number` only has 52 safely-available bits. n: Nonce; + /** + * @param k encryption key + * @param n nonce + */ constructor(k: bytes32 = createEmptyKey(), n = new Nonce()) { this.k = k; this.n = n; } + /** + * Create a copy of the CipherState + * @returns a copy of the CipherState + */ clone(): CipherState { return new CipherState(new Uint8Array(this.k), new Nonce(this.n.getUint64())); } - equals(b: CipherState): boolean { - return uint8ArrayEquals(this.k, b.getKey()) && this.n.getUint64() == b.getNonce().getUint64(); + /** + * Check CipherState equality + * @param other object to compare against + * @returns true if equal, false otherwise + */ + equals(other: CipherState): boolean { + return uint8ArrayEquals(this.k, other.getKey()) && this.n.getUint64() == other.getNonce().getUint64(); } - // Checks if a Cipher State has an encryption key set + /** + * Checks if a Cipher State has an encryption key set + * @returns true if a key is set, false otherwise` + */ protected hasKey(): boolean { return !isEmptyKey(this.k); } - // Encrypts a plaintext using key material in a Noise Cipher State - // The CipherState is updated increasing the nonce (used as a counter in Noise) by one + /** + * Encrypts a plaintext using key material in a Noise Cipher State + * The CipherState is updated increasing the nonce (used as a counter in Noise) by one + * @param ad associated data + * @param plaintext data to encrypt + */ encryptWithAd(ad: Uint8Array, plaintext: Uint8Array): Uint8Array { this.n.assertValue(); @@ -96,8 +127,12 @@ export class CipherState { return ciphertext; } - // Decrypts a ciphertext using key material in a Noise Cipher State - // The CipherState is updated increasing the nonce (used as a counter in Noise) by one + /** + * Decrypts a ciphertext using key material in a Noise Cipher State + * The CipherState is updated increasing the nonce (used as a counter in Noise) by one + * @param ad associated data + * @param ciphertext data to decrypt + */ decryptWithAd(ad: Uint8Array, ciphertext: Uint8Array): Uint8Array { this.n.assertValue(); @@ -119,27 +154,44 @@ export class CipherState { } } - // Sets the nonce of a Cipher State + /** + * Sets the nonce of a Cipher State + * @param nonce Nonce + */ setNonce(nonce: Nonce): void { this.n = nonce; } - // Sets the key of a Cipher State + /** + * Sets the key of a Cipher State + * @param key set the cipherstate encryption key + */ setCipherStateKey(key: bytes32): void { this.k = key; } - // Gets the key of a Cipher State + /** + * Gets the encryption key of a Cipher State + * @returns encryption key + */ getKey(): bytes32 { return this.k; } - // Gets the nonce of a Cipher State + /** + * Gets the nonce of a Cipher State + * @returns Nonce + */ getNonce(): Nonce { return this.n; } } +/** + * Hash protocol name + * @param name name of the noise handshake pattern to hash + * @returns sha256 digest of the protocol name + */ function hashProtocol(name: string): Uint8Array { // If protocol_name is less than or equal to HASHLEN bytes in length, // sets h equal to protocol_name with zero bytes appended to make HASHLEN bytes. @@ -155,8 +207,10 @@ function hashProtocol(name: string): Uint8Array { } } -// The Symmetric State as in https://noiseprotocol.org/noise.html#the-symmetricstate-object -// Contains a Cipher State cs, the chaining key ck and the handshake hash value h +/** + * The Symmetric State as in https://noiseprotocol.org/noise.html#the-symmetricstate-object + * Contains a Cipher State cs, the chaining key ck and the handshake hash value h + */ export class SymmetricState { cs: CipherState; h: bytes32; // handshake hash @@ -169,15 +223,24 @@ export class SymmetricState { this.hsPattern = hsPattern; } - equals(b: SymmetricState): boolean { + /** + * Check CipherState equality + * @param other object to compare against + * @returns true if equal, false otherwise + */ + equals(other: SymmetricState): boolean { return ( - this.cs.equals(b.cs) && - uint8ArrayEquals(this.ck, b.ck) && - uint8ArrayEquals(this.h, b.h) && - this.hsPattern.equals(b.hsPattern) + this.cs.equals(other.cs) && + uint8ArrayEquals(this.ck, other.ck) && + uint8ArrayEquals(this.h, other.h) && + this.hsPattern.equals(other.hsPattern) ); } + /** + * Create a copy of the SymmetricState + * @returns a copy of the SymmetricState + */ clone(): SymmetricState { const ss = new SymmetricState(this.hsPattern); ss.cs = this.cs.clone(); @@ -186,30 +249,39 @@ export class SymmetricState { return ss; } - // MixKey as per Noise specification http://www.noiseprotocol.org/noise.html#the-symmetricstate-object - // Updates a Symmetric state chaining key and symmetric state + /** + * MixKey as per Noise specification http://www.noiseprotocol.org/noise.html#the-symmetricstate-object + * Updates a Symmetric state chaining key and symmetric state + * @param inputKeyMaterial + */ mixKey(inputKeyMaterial: Uint8Array): void { // We derive two keys using HKDF - const [ck, tempK] = getHKDF(this.ck, inputKeyMaterial); + const [ck, tempK] = HKDF(this.ck, inputKeyMaterial, 32, 2); // We update ck and the Cipher state's key k using the output of HDKF this.cs = new CipherState(tempK); this.ck = ck; log("mixKey", this.ck, this.cs.k); } - // MixHash as per Noise specification http://www.noiseprotocol.org/noise.html#the-symmetricstate-object - // Hashes data into a Symmetric State's handshake hash value h + /** + * MixHash as per Noise specification http://www.noiseprotocol.org/noise.html#the-symmetricstate-object + * Hashes data into a Symmetric State's handshake hash value h + * @param data input data to hash into h + */ mixHash(data: Uint8Array): void { // We hash the previous handshake hash and input data and store the result in the Symmetric State's handshake hash value this.h = hashSHA256(uint8ArrayConcat([this.h, data])); log("mixHash", this.h); } - // mixKeyAndHash as per Noise specification http://www.noiseprotocol.org/noise.html#the-symmetricstate-object - // Combines MixKey and MixHash + /** + * mixKeyAndHash as per Noise specification http://www.noiseprotocol.org/noise.html#the-symmetricstate-object + * Combines MixKey and MixHash + * @param inputKeyMaterial + */ mixKeyAndHash(inputKeyMaterial: Uint8Array): void { // Derives 3 keys using HKDF, the chaining key and the input key material - const [tmpKey0, tmpKey1, tmpKey2] = getHKDF(this.ck, inputKeyMaterial); + const [tmpKey0, tmpKey1, tmpKey2] = HKDF(this.ck, inputKeyMaterial, 32, 3); // Sets the chaining key this.ck = tmpKey0; // Updates the handshake hash value @@ -219,9 +291,14 @@ export class SymmetricState { this.cs = new CipherState(tmpKey2); } - // EncryptAndHash as per Noise specification http://www.noiseprotocol.org/noise.html#the-symmetricstate-object - // Combines encryptWithAd and mixHash - // Note that by setting extraAd, it is possible to pass extra additional data that will be concatenated to the ad specified by Noise (can be used to authenticate messageNametag) + /** + * EncryptAndHash as per Noise specification http://www.noiseprotocol.org/noise.html#the-symmetricstate-object + * Combines encryptWithAd and mixHash + * Note that by setting extraAd, it is possible to pass extra additional data that will be concatenated to the ad + * specified by Noise (can be used to authenticate messageNametag) + * @param plaintext data to encrypt + * @param extraAd extra additional data + */ encryptAndHash(plaintext: Uint8Array, extraAd: Uint8Array = new Uint8Array()): Uint8Array { // The additional data const ad = uint8ArrayConcat([this.h, extraAd]); @@ -233,8 +310,12 @@ export class SymmetricState { return ciphertext; } - // DecryptAndHash as per Noise specification http://www.noiseprotocol.org/noise.html#the-symmetricstate-object - // Combines decryptWithAd and mixHash + /** + * DecryptAndHash as per Noise specification http://www.noiseprotocol.org/noise.html#the-symmetricstate-object + * Combines decryptWithAd and mixHash + * @param ciphertext data to decrypt + * @param extraAd extra additional data + */ decryptAndHash(ciphertext: Uint8Array, extraAd: Uint8Array = new Uint8Array()): Uint8Array { // The additional data const ad = uint8ArrayConcat([this.h, extraAd]); @@ -246,11 +327,14 @@ export class SymmetricState { return plaintext; } - // Split as per Noise specification http://www.noiseprotocol.org/noise.html#the-symmetricstate-object - // Once a handshake is complete, returns two Cipher States to encrypt/decrypt outbound/inbound messages + /** + * Split as per Noise specification http://www.noiseprotocol.org/noise.html#the-symmetricstate-object + * Once a handshake is complete, returns two Cipher States to encrypt/decrypt outbound/inbound messages + * @returns CipherState to encrypt and CipherState to decrypt + */ split(): { cs1: CipherState; cs2: CipherState } { // Derives 2 keys using HKDF and the chaining key - const [tmpKey1, tmpKey2] = getHKDF(this.ck, new Uint8Array(0)); + const [tmpKey1, tmpKey2] = HKDF(this.ck, new Uint8Array(0), 32, 2); // Returns a tuple of two Cipher States initialized with the derived keys return { cs1: new CipherState(tmpKey1), @@ -258,17 +342,26 @@ export class SymmetricState { }; } - // Gets the chaining key field of a Symmetric State + /** + * Gets the chaining key field of a Symmetric State + * @returns Chaining key + */ getChainingKey(): bytes32 { return this.ck; } - // Gets the handshake hash field of a Symmetric State + /** + * Gets the handshake hash field of a Symmetric State + * @returns Handshake hash + */ getHandshakeHash(): bytes32 { return this.h; } - // Gets the Cipher State field of a Symmetric State + /** + * Gets the Cipher State field of a Symmetric State + * @returns Cipher State + */ getCipherState(): CipherState { return this.cs; } diff --git a/src/nonce.ts b/src/nonce.ts index 1b266fa..c7afac6 100644 --- a/src/nonce.ts +++ b/src/nonce.ts @@ -1,4 +1,6 @@ -import type { bytes, uint64 } from "./@types/basic.js"; +// Adapted from https://github.com/ChainSafe/js-libp2p-noise/blob/master/src/nonce.ts + +import type { uint64 } from "./@types/basic.js"; export const MIN_NONCE = 0; // For performance reasons, the nonce is represented as a JS `number` @@ -17,7 +19,7 @@ const ERR_MAX_NONCE = "Cipherstate has reached maximum n, a new handshake must b */ export class Nonce { private n: uint64; - private readonly bytes: bytes; + private readonly bytes: Uint8Array; private readonly view: DataView; constructor(n = MIN_NONCE) { @@ -33,7 +35,7 @@ export class Nonce { this.view.setUint32(4, this.n, true); } - getBytes(): bytes { + getBytes(): Uint8Array { return this.bytes; } diff --git a/src/pairing.spec.ts b/src/pairing.spec.ts index f7f9586..677202a 100644 --- a/src/pairing.spec.ts +++ b/src/pairing.spec.ts @@ -8,8 +8,8 @@ import { equals as uint8ArrayEquals } from "uint8arrays/equals"; import { NoiseHandshakeMessage } from "./codec"; import { generateX25519KeyPair } from "./crypto"; +import { MessageNametagBufferSize } from "./messagenametag"; import { ResponderParameters, WakuPairing } from "./pairing"; -import { MessageNametagBufferSize } from "./payload"; describe("js-noise: pairing object", () => { const rng = new HMACDRBG(); diff --git a/src/pairing.ts b/src/pairing.ts index c1da631..50ff9ab 100644 --- a/src/pairing.ts +++ b/src/pairing.ts @@ -16,27 +16,48 @@ import { } from "./codec.js"; import { commitPublicKey, generateX25519KeyPair } from "./crypto.js"; import { Handshake, HandshakeResult, HandshakeStepResult, MessageNametagError } from "./handshake.js"; +import { MessageNametagLength } from "./messagenametag.js"; import { NoiseHandshakePatterns } from "./patterns.js"; -import { MessageNametagLength } from "./payload.js"; import { NoisePublicKey } from "./publickey.js"; import { QR } from "./qr.js"; const log = debug("waku:noise:pairing"); +/** + * Sender interface that an object must implement so the pairing object can publish noise messages + */ export interface Sender { + /** + * Publish a message + * @param encoder NoiseHandshakeEncoder encoder to use to encrypt the messages + * @param msg message to broadcast + */ publish(encoder: Encoder, msg: Message): Promise; } +/** + * Responder interface than an object must implement so the pairing object can receive noise messages + */ export interface Responder { + /** + * subscribe to receive the messages from a content topic + * @param decoder Decoder to use to decrypt the NoiseHandshakeMessages + */ subscribe(decoder: Decoder): Promise; - // next message should return messages received in a content topic - // messages should be kept in a queue, meaning that nextMessage - // will call pop in the queue to remove the oldest message received - // (it's important to maintain order of received messages) + /** + * should return messages received in a content topic + * messages should be kept in a queue, meaning that nextMessage + * will call pop in the queue to remove the oldest message received + * (it's important to maintain order of received messages) + * @param contentTopic content topic to get the next message from + */ nextMessage(contentTopic: string): Promise; - // this should stop the subscription + /** + * Stop the subscription to the content topic + * @param contentTopic + */ stop(contentTopic: string): Promise; } @@ -46,10 +67,16 @@ function delay(ms: number): Promise { const rng = new HMACDRBG(); +/** + * Initiator parameters used to setup the pairing object + */ export class InitiatorParameters { constructor(public readonly qrCode: string, public readonly qrMessageNameTag: Uint8Array) {} } +/** + * Responder parameters used to setup the pairing object + */ export class ResponderParameters { constructor( public readonly applicationName: string = "waku-noise-sessions", @@ -58,6 +85,9 @@ export class ResponderParameters { ) {} } +/** + * Pairing object to setup a noise session + */ export class WakuPairing { public readonly contentTopic: string; @@ -73,12 +103,24 @@ export class WakuPairing { private eventEmitter = new EventEmitter(); + /** + * Convert a QR into a content topic + * @param qr + * @returns content topic string + */ private static toContentTopic(qr: QR): string { return ( "/" + qr.applicationName + "/" + qr.applicationVersion + "/wakunoise/1/sessions_shard-" + qr.shardId + "/proto" ); } + /** + * @param sender object that implements Sender interface to publish waku messages + * @param responder object that implements Responder interface to subscribe and receive waku messages + * @param myStaticKey x25519 keypair + * @param pairingParameters Pairing parameters (depending if this is the initiator or responder) + * @param myEphemeralKey optional ephemeral key + */ constructor( private sender: Sender, private responder: Responder, @@ -91,7 +133,7 @@ export class WakuPairing { if (pairingParameters instanceof InitiatorParameters) { this.initiator = true; - this.qr = QR.fromString(pairingParameters.qrCode); + this.qr = QR.from(pairingParameters.qrCode); this.qrMessageNameTag = pairingParameters.qrMessageNameTag; } else { this.initiator = false; @@ -104,6 +146,7 @@ export class WakuPairing { this.myCommittedStaticKey ); } + // We set the contentTopic from the content topic parameters exchanged in the QR this.contentTopic = WakuPairing.toContentTopic(this.qr); @@ -121,10 +164,19 @@ export class WakuPairing { }); } + /** + * Get pairing information (as an InitiatorParameter object) + * @returns InitiatorParameters + */ public getPairingInfo(): InitiatorParameters { return new InitiatorParameters(this.qr.toString(), this.qrMessageNameTag); } + /** + * Get auth code (to validate that pairing). It must be displayed on both + * devices and the user(s) must confirm if the auth code match + * @returns Promise that resolves to an auth code + */ public async getAuthCode(): Promise { return new Promise((resolve) => { if (this.authCode) { @@ -138,8 +190,14 @@ export class WakuPairing { }); } - public validateAuthCode(confirmed: boolean): void { - this.eventEmitter.emit("confirmAuthCode", confirmed); + /** + * Indicate if auth code is valid. This is a function that must be + * manually called by the user(s) if the auth code in both devices being + * paired match. If false, pairing session is terminated + * @param isValid true if authcode is correct, false otherwise. + */ + public validateAuthCode(isValid: boolean): void { + this.eventEmitter.emit("confirmAuthCode", isValid); } private async isAuthCodeConfirmed(): Promise { @@ -301,6 +359,13 @@ export class WakuPairing { return WakuPairing.getSecureCodec(this.contentTopic, this.handshakeResult); } + /** + * Get codecs for encoding/decoding messages in js-waku. This function can be used + * to continue a session using a stored hsResult + * @param contentTopic Content topic for the waku messages + * @param hsResult Noise Pairing result + * @returns an array with [NoiseSecureTransferEncoder, NoiseSecureTransferDecoder] + */ static getSecureCodec( contentTopic: string, hsResult: HandshakeResult @@ -311,6 +376,10 @@ export class WakuPairing { return [secureEncoder, secureDecoder]; } + /** + * Get handshake result + * @returns result of a successful pairing + */ public getHandshakeResult(): HandshakeResult { if (!this.handshakeResult) { throw new Error("handshake is not complete"); @@ -318,14 +387,19 @@ export class WakuPairing { return this.handshakeResult; } - async execute(timeoutMs = 30000): Promise<[NoiseSecureTransferEncoder, NoiseSecureTransferDecoder]> { + /** + * Execute handshake + * @param timeoutMs Timeout in milliseconds after which the pairing session is invalid + * @returns promise that resolves to codecs for encoding/decoding messages in js-waku + */ + async execute(timeoutMs = 60000): Promise<[NoiseSecureTransferEncoder, NoiseSecureTransferDecoder]> { if (this.started) { throw new Error("pairing already executed. Create new pairing object"); } this.started = true; return new Promise((resolve, reject) => { - // Limit QR exposure to 30s + // Limit QR exposure to some timeout const timer = setTimeout(() => { reject(new Error("pairing has timed out")); this.eventEmitter.emit("pairingTimeout"); diff --git a/src/patterns.ts b/src/patterns.ts index c7c8827..cd1aa05 100644 --- a/src/patterns.ts +++ b/src/patterns.ts @@ -1,6 +1,7 @@ -// The Noise tokens appearing in Noise (pre)message patterns - -// as in http://www.noiseprotocol.org/noise.html#handshake-pattern-basics +/** + * The Noise tokens appearing in Noise (pre)message patterns + * as in http://www.noiseprotocol.org/noise.html#handshake-pattern-basics + */ export enum NoiseTokens { e = "e", s = "s", @@ -11,42 +12,61 @@ export enum NoiseTokens { psk = "psk", } -// The direction of a (pre)message pattern in canonical form (i.e. Alice-initiated form) -// as in http://www.noiseprotocol.org/noise.html#alice-and-bob +/** + * The direction of a (pre)message pattern in canonical form (i.e. Alice-initiated form) + * as in http://www.noiseprotocol.org/noise.html#alice-and-bob + */ export enum MessageDirection { r = "->", l = "<-", } -// The pre message pattern consisting of a message direction and some Noise tokens, if any. -// (if non empty, only tokens e and s are allowed: http://www.noiseprotocol.org/noise.html#handshake-pattern-basics) +/** + * The pre message pattern consisting of a message direction and some Noise tokens, if any. + * (if non empty, only tokens e and s are allowed: http://www.noiseprotocol.org/noise.html#handshake-pattern-basics) + */ export class PreMessagePattern { constructor(public readonly direction: MessageDirection, public readonly tokens: Array) {} - equals(b: PreMessagePattern): boolean { + /** + * Check PreMessagePattern equality + * @param other object to compare against + * @returns true if equal, false otherwise + */ + equals(other: PreMessagePattern): boolean { return ( - this.direction == b.direction && - this.tokens.length === b.tokens.length && - this.tokens.every((val, index) => val === b.tokens[index]) + this.direction == other.direction && + this.tokens.length === other.tokens.length && + this.tokens.every((val, index) => val === other.tokens[index]) ); } } -// The message pattern consisting of a message direction and some Noise tokens -// All Noise tokens are allowed +/** + * The message pattern consisting of a message direction and some Noise tokens + * All Noise tokens are allowed + */ export class MessagePattern { constructor(public readonly direction: MessageDirection, public readonly tokens: Array) {} - equals(b: MessagePattern): boolean { + /** + * Check MessagePattern equality + * @param other object to compare against + * @returns true if equal, false otherwise + */ + equals(other: MessagePattern): boolean { return ( - this.direction == b.direction && - this.tokens.length === b.tokens.length && - this.tokens.every((val, index) => val === b.tokens[index]) + this.direction == other.direction && + this.tokens.length === other.tokens.length && + this.tokens.every((val, index) => val === other.tokens[index]) ); } } -// The handshake pattern object. It stores the handshake protocol name, the handshake pre message patterns and the handshake message patterns +/** + * The handshake pattern object. It stores the handshake protocol name, the + * handshake pre message patterns and the handshake message patterns + */ export class HandshakePattern { constructor( public readonly name: string, @@ -54,25 +74,29 @@ export class HandshakePattern { public readonly messagePatterns: Array ) {} - equals(b: HandshakePattern): boolean { - if (this.preMessagePatterns.length != b.preMessagePatterns.length) return false; + /** + * Check HandshakePattern equality + * @param other object to compare against + * @returns true if equal, false otherwise + */ + equals(other: HandshakePattern): boolean { + if (this.preMessagePatterns.length != other.preMessagePatterns.length) return false; for (let i = 0; i < this.preMessagePatterns.length; i++) { - if (!this.preMessagePatterns[i].equals(b.preMessagePatterns[i])) return false; + if (!this.preMessagePatterns[i].equals(other.preMessagePatterns[i])) return false; } - if (this.messagePatterns.length != b.messagePatterns.length) return false; + if (this.messagePatterns.length != other.messagePatterns.length) return false; for (let i = 0; i < this.messagePatterns.length; i++) { - if (!this.messagePatterns[i].equals(b.messagePatterns[i])) return false; + if (!this.messagePatterns[i].equals(other.messagePatterns[i])) return false; } - return this.name == b.name; + return this.name == other.name; } } -// Constants (supported protocols) -export const EmptyPreMessage = new Array(); - -// Supported Noise handshake patterns as defined in https://rfc.vac.dev/spec/35/#specification +/** + * Supported Noise handshake patterns as defined in https://rfc.vac.dev/spec/35/#specification + */ export const NoiseHandshakePatterns = { K1K1: new HandshakePattern( "Noise_K1K1_25519_ChaChaPoly_SHA256", @@ -95,16 +119,24 @@ export const NoiseHandshakePatterns = { new MessagePattern(MessageDirection.r, [NoiseTokens.s, NoiseTokens.se]), ] ), - XX: new HandshakePattern("Noise_XX_25519_ChaChaPoly_SHA256", EmptyPreMessage, [ - new MessagePattern(MessageDirection.r, [NoiseTokens.e]), - new MessagePattern(MessageDirection.l, [NoiseTokens.e, NoiseTokens.ee, NoiseTokens.s, NoiseTokens.es]), - new MessagePattern(MessageDirection.r, [NoiseTokens.s, NoiseTokens.se]), - ]), - XXpsk0: new HandshakePattern("Noise_XXpsk0_25519_ChaChaPoly_SHA256", EmptyPreMessage, [ - new MessagePattern(MessageDirection.r, [NoiseTokens.psk, NoiseTokens.e]), - new MessagePattern(MessageDirection.l, [NoiseTokens.e, NoiseTokens.ee, NoiseTokens.s, NoiseTokens.es]), - new MessagePattern(MessageDirection.r, [NoiseTokens.s, NoiseTokens.se]), - ]), + XX: new HandshakePattern( + "Noise_XX_25519_ChaChaPoly_SHA256", + [], + [ + new MessagePattern(MessageDirection.r, [NoiseTokens.e]), + new MessagePattern(MessageDirection.l, [NoiseTokens.e, NoiseTokens.ee, NoiseTokens.s, NoiseTokens.es]), + new MessagePattern(MessageDirection.r, [NoiseTokens.s, NoiseTokens.se]), + ] + ), + XXpsk0: new HandshakePattern( + "Noise_XXpsk0_25519_ChaChaPoly_SHA256", + [], + [ + new MessagePattern(MessageDirection.r, [NoiseTokens.psk, NoiseTokens.e]), + new MessagePattern(MessageDirection.l, [NoiseTokens.e, NoiseTokens.ee, NoiseTokens.s, NoiseTokens.es]), + new MessagePattern(MessageDirection.r, [NoiseTokens.s, NoiseTokens.se]), + ] + ), WakuPairing: new HandshakePattern( "Noise_WakuPairing_25519_ChaChaPoly_SHA256", [new PreMessagePattern(MessageDirection.l, [NoiseTokens.e])], @@ -116,8 +148,10 @@ export const NoiseHandshakePatterns = { ), }; -// Supported Protocol ID for PayloadV2 objects -// Protocol IDs are defined according to https://rfc.vac.dev/spec/35/#specification +/** + * Supported Protocol ID for PayloadV2 objects + * Protocol IDs are defined according to https://rfc.vac.dev/spec/35/#specification + */ export const PayloadV2ProtocolIDs: { [id: string]: number } = { "": 0, Noise_K1K1_25519_ChaChaPoly_SHA256: 10, diff --git a/src/payload.ts b/src/payload.ts index ce7d61e..f1bb441 100644 --- a/src/payload.ts +++ b/src/payload.ts @@ -1,118 +1,19 @@ -// PayloadV2 defines an object for Waku payloads with version 2 as in -// https://rfc.vac.dev/spec/35/#public-keys-serialization -// It contains a message nametag, protocol ID field, the handshake message (for Noise handshakes) and - import { concat as uint8ArrayConcat } from "uint8arrays/concat"; import { equals as uint8ArrayEquals } from "uint8arrays/equals"; import { MessageNametag } from "./@types/handshake.js"; -import { ChachaPolyTagLen, Curve25519KeySize, hashSHA256 } from "./crypto.js"; +import { ChachaPolyTagLen, Curve25519KeySize } from "./crypto.js"; +import { MessageNametagLength } from "./messagenametag.js"; import { PayloadV2ProtocolIDs } from "./patterns.js"; import { NoisePublicKey } from "./publickey.js"; import { readUIntLE, writeUIntLE } from "./utils.js"; -export const MessageNametagLength = 16; -export const MessageNametagBufferSize = 50; - -// Converts a sequence or array (arbitrary size) to a MessageNametag -export function toMessageNametag(input: Uint8Array): MessageNametag { - return input.subarray(0, MessageNametagLength); -} - -export class MessageNametagBuffer { - private buffer: Array = new Array(MessageNametagBufferSize); - private counter = 0; - secret?: Uint8Array; - - constructor() { - for (let i = 0; i < this.buffer.length; i++) { - this.buffer[i] = new Uint8Array(MessageNametagLength); - } - } - - // Initializes the empty Message nametag buffer. The n-th nametag is equal to HKDF( secret || n ) - initNametagsBuffer(): void { - // We default the counter and buffer fields - this.counter = 0; - this.buffer = new Array(MessageNametagBufferSize); - - if (this.secret) { - for (let i = 0; i < this.buffer.length; i++) { - const counterBytesLE = writeUIntLE(new Uint8Array(8), this.counter, 0, 8); - const d = hashSHA256(uint8ArrayConcat([this.secret, counterBytesLE])); - this.buffer[i] = toMessageNametag(d); - this.counter++; - } - } else { - // We warn users if no secret is set - console.debug("The message nametags buffer has not a secret set"); - } - } - - pop(): MessageNametag { - // Note that if the input MessageNametagBuffer is set to default, an all 0 messageNametag is returned - const messageNametag = new Uint8Array(this.buffer[0]); - this.delete(1); - return messageNametag; - } - - // Checks if the input messageNametag is contained in the input MessageNametagBuffer - checkNametag(messageNametag: MessageNametag): boolean { - const index = this.buffer.findIndex((x) => uint8ArrayEquals(x, messageNametag)); - - if (index == -1) { - console.debug("Message nametag not found in buffer"); - return false; - } else if (index > 0) { - console.debug( - "Message nametag is present in buffer but is not the next expected nametag. One or more messages were probably lost" - ); - return false; - } - - // index is 0, hence the read message tag is the next expected one - return true; - } - - rotateLeft(k: number): void { - if (k < 0 || this.buffer.length == 0) { - return; - } - const idx = this.buffer.length - (k % this.buffer.length); - const a1 = this.buffer.slice(idx); - const a2 = this.buffer.slice(0, idx); - this.buffer = a1.concat(a2); - } - - // Deletes the first n elements in buffer and appends n new ones - delete(n: number): void { - if (n <= 0) { - return; - } - - // We ensure n is at most MessageNametagBufferSize (the buffer will be fully replaced) - n = Math.min(n, MessageNametagBufferSize); - - // We update the last n values in the array if a secret is set - // Note that if the input MessageNametagBuffer is set to default, nothing is done here - if (this.secret) { - // We rotate left the array by n - this.rotateLeft(n); - - for (let i = 0; i < n; i++) { - const counterBytesLE = writeUIntLE(new Uint8Array(8), this.counter, 0, 8); - const d = hashSHA256(uint8ArrayConcat([this.secret, counterBytesLE])); - - this.buffer[this.buffer.length - n + i] = toMessageNametag(d); - this.counter++; - } - } else { - // We warn users that no secret is set - console.debug("The message nametags buffer has no secret set"); - } - } -} - +/** + * PayloadV2 defines an object for Waku payloads with version 2 as in + * https://rfc.vac.dev/spec/35/#public-keys-serialization + * It contains a message nametag, protocol ID field, the handshake message (for Noise handshakes) + * and the transport message + */ export class PayloadV2 { messageNametag: MessageNametag; protocolId: number; @@ -131,6 +32,10 @@ export class PayloadV2 { this.transportMessage = transportMessage; } + /** + * Create a copy of the PayloadV2 + * @returns a copy of the PayloadV2 + */ clone(): PayloadV2 { const r = new PayloadV2(); r.protocolId = this.protocolId; @@ -142,31 +47,39 @@ export class PayloadV2 { return r; } - equals(b: PayloadV2): boolean { + /** + * Check PayloadV2 equality + * @param other object to compare against + * @returns true if equal, false otherwise + */ + equals(other: PayloadV2): boolean { let pkEquals = true; - if (this.handshakeMessage.length != b.handshakeMessage.length) { + if (this.handshakeMessage.length != other.handshakeMessage.length) { pkEquals = false; } for (let i = 0; i < this.handshakeMessage.length; i++) { - if (!this.handshakeMessage[i].equals(b.handshakeMessage[i])) { + if (!this.handshakeMessage[i].equals(other.handshakeMessage[i])) { pkEquals = false; break; } } return ( - uint8ArrayEquals(this.messageNametag, b.messageNametag) && - this.protocolId == b.protocolId && - uint8ArrayEquals(this.transportMessage, b.transportMessage) && + uint8ArrayEquals(this.messageNametag, other.messageNametag) && + this.protocolId == other.protocolId && + uint8ArrayEquals(this.transportMessage, other.transportMessage) && pkEquals ); } - // Serializes a PayloadV2 object to a byte sequences according to https://rfc.vac.dev/spec/35/. - // The output serialized payload concatenates the input PayloadV2 object fields as - // payload = ( protocolId || serializedHandshakeMessageLen || serializedHandshakeMessage || transportMessageLen || transportMessage) - // The output can be then passed to the payload field of a WakuMessage https://rfc.vac.dev/spec/14/ + /** + * Serializes a PayloadV2 object to a byte sequences according to https://rfc.vac.dev/spec/35/. + * The output serialized payload concatenates the input PayloadV2 object fields as + * payload = ( protocolId || serializedHandshakeMessageLen || serializedHandshakeMessage || transportMessageLen || transportMessage) + * The output can be then passed to the payload field of a WakuMessage https://rfc.vac.dev/spec/14/ + * @returns serialized payload + */ serialize(): Uint8Array { // We collect public keys contained in the handshake message @@ -210,9 +123,11 @@ export class PayloadV2 { return payload; } - // Deserializes a byte sequence to a PayloadV2 object according to https://rfc.vac.dev/spec/35/. - // The input serialized payload concatenates the output PayloadV2 object fields as - // payload = ( messageNametag || protocolId || serializedHandshakeMessageLen || serializedHandshakeMessage || transportMessageLen || transportMessage) + /** + * Deserializes a byte sequence to a PayloadV2 object according to https://rfc.vac.dev/spec/35/. + * @param payload input serialized payload + * @returns PayloadV2 + */ static deserialize(payload: Uint8Array): PayloadV2 { // i is the read input buffer position index let i = 0; diff --git a/src/publickey.ts b/src/publickey.ts index 86e29b5..d22ceeb 100644 --- a/src/publickey.ts +++ b/src/publickey.ts @@ -5,19 +5,31 @@ import { bytes32 } from "./@types/basic.js"; import { chaCha20Poly1305Decrypt, chaCha20Poly1305Encrypt } from "./crypto.js"; import { isEmptyKey } from "./noise.js"; -// A ChaChaPoly Cipher State containing key (k), nonce (nonce) and associated data (ad) +/** + * A ChaChaPoly Cipher State containing key (k), nonce (nonce) and associated data (ad) + */ export class ChaChaPolyCipherState { k: bytes32; nonce: bytes32; ad: Uint8Array; + + /** + * @param k 32-byte key + * @param nonce 12 byte little-endian nonce + * @param ad associated data + */ 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 + /** + * Takes a Cipher State (with key, nonce, and associated data) and encrypts a plaintext. + * The cipher state in not changed + * @param plaintext data to encrypt + * @returns sealed ciphertext including authentication tag + */ encrypt(plaintext: Uint8Array): Uint8Array { // If plaintext is empty, we raise an error if (plaintext.length == 0) { @@ -27,9 +39,12 @@ export class ChaChaPolyCipherState { 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 + /** + * Takes a Cipher State (with key, nonce, and associated data) and decrypts a ciphertext + * The cipher state is not changed + * @param ciphertext data to decrypt + * @returns plaintext + */ decrypt(ciphertext: Uint8Array): Uint8Array { // If ciphertext is empty, we raise an error if (ciphertext.length == 0) { @@ -44,30 +59,49 @@ export class ChaChaPolyCipherState { } } -// 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 -// pk contains the X coordinate of the public key, if unencrypted (this implies flag = 0) -// or the encryption of the X coordinate concatenated with the authorization tag, if encrypted (this implies flag = 1) -// Note: besides encryption, flag can be used to distinguish among multiple supported Elliptic Curves +/** + * 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 + */ export class NoisePublicKey { + /** + * @param flag 1 to indicate that the public key is encrypted, 0 for unencrypted. + * Note: besides encryption, flag can be used to distinguish among multiple supported Elliptic Curves + * @param pk contains the X coordinate of the public key, if unencrypted + * or the encryption of the X coordinate concatenated with the authorization tag, if encrypted + */ constructor(public readonly flag: number, public readonly pk: Uint8Array) {} + /** + * Create a copy of the NoisePublicKey + * @returns a copy of the NoisePublicKey + */ clone(): NoisePublicKey { return new NoisePublicKey(this.flag, new Uint8Array(this.pk)); } - // Checks equality between two Noise public keys - equals(k2: NoisePublicKey): boolean { - return this.flag == k2.flag && uint8ArrayEquals(this.pk, k2.pk); + /** + * Check NoisePublicKey equality + * @param other object to compare against + * @returns true if equal, false otherwise + */ + equals(other: NoisePublicKey): boolean { + return this.flag == other.flag && uint8ArrayEquals(this.pk, other.pk); } - // Converts a public Elliptic Curve key to an unencrypted Noise public key + /** + * Converts a public Elliptic Curve key to an unencrypted Noise public key + * @param publicKey 32-byte public key + * @returns 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 + /** + * Converts a Noise public key to a stream of bytes as in https://rfc.vac.dev/spec/35/#public-keys-serialization + * @returns Serialized NoisePublicKey + */ serialize(): Uint8Array { // Public key is serialized as (flag || pk) // Note that pk contains the X coordinate of the public key if unencrypted @@ -76,8 +110,11 @@ export class NoisePublicKey { return serializedNoisePublicKey; } - // Converts a serialized Noise public key to a NoisePublicKey object as in - // https://rfc.vac.dev/spec/35/#public-keys-serialization + /** + * Converts a serialized Noise public key to a NoisePublicKey object as in https://rfc.vac.dev/spec/35/#public-keys-serialization + * @param serializedPK Serialized NoisePublicKey + * @returns NoisePublicKey + */ static deserialize(serializedPK: Uint8Array): NoisePublicKey { if (serializedPK.length == 0) throw new Error("invalid serialized key"); @@ -90,30 +127,41 @@ export class NoisePublicKey { return new NoisePublicKey(flag, pk); } - static encrypt(ns: NoisePublicKey, cs: ChaChaPolyCipherState): NoisePublicKey { + /** + * Encrypt a NoisePublicKey using a ChaChaPolyCipherState + * @param pk NoisePublicKey to encrypt + * @param cs ChaChaPolyCipherState used to encrypt + * @returns encrypted NoisePublicKey + */ + static encrypt(pk: 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); + if (!isEmptyKey(cs.k) && pk.flag == 0) { + const encPk = cs.encrypt(pk.pk); return new NoisePublicKey(1, encPk); } // Otherwise we return the public key as it is - return ns.clone(); + return pk.clone(); } - // Decrypts a Noise public key using a ChaChaPoly Cipher State - static decrypt(ns: NoisePublicKey, cs: ChaChaPolyCipherState): NoisePublicKey { + /** + * Decrypts a Noise public key using a ChaChaPoly Cipher State + * @param pk NoisePublicKey to decrypt + * @param cs ChaChaPolyCipherState used to decrypt + * @returns decrypted NoisePublicKey + */ + static decrypt(pk: 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); + if (!isEmptyKey(cs.k) && pk.flag == 1) { + const decrypted = cs.decrypt(pk.pk); return new NoisePublicKey(0, decrypted); } // Otherwise we return the public key as it is - return ns.clone(); + return pk.clone(); } } diff --git a/src/qr.ts b/src/qr.ts index 45b8340..f780a77 100644 --- a/src/qr.ts +++ b/src/qr.ts @@ -2,6 +2,9 @@ import { decode, encode, fromUint8Array, toUint8Array } from "js-base64"; import { bytes32 } from "./@types/basic.js"; +/** + * QR code generation + */ export class QR { constructor( public readonly applicationName: string, @@ -22,14 +25,30 @@ export class QR { return qr; } + /** + * Convert QR code into byte array + * @returns byte array serialization of a base64 encoded QR code + */ toByteArray(): Uint8Array { const enc = new TextEncoder(); return enc.encode(this.toString()); } - // Deserializes input string in base64 to the corresponding (applicationName, applicationVersion, shardId, ephemeralKey, committedStaticKey) - static fromString(qrString: string): QR { - const values = qrString.split(":"); + /** + * Deserializes input string in base64 to the corresponding (applicationName, applicationVersion, shardId, ephemeralKey, committedStaticKey) + * @param input input base64 encoded string + * @returns QR + */ + static from(input: string | Uint8Array): QR { + let qrStr: string; + if (input instanceof Uint8Array) { + const dec = new TextDecoder(); + qrStr = dec.decode(input); + } else { + qrStr = input; + } + + const values = qrStr.split(":"); if (values.length != 5) throw new Error("invalid qr string"); diff --git a/src/waku-noise-pairing.spec.ts b/src/waku-noise-pairing.spec.ts index 1787462..56a4383 100644 --- a/src/waku-noise-pairing.spec.ts +++ b/src/waku-noise-pairing.spec.ts @@ -11,8 +11,8 @@ import { } from "./codec"; import { commitPublicKey, generateX25519KeyPair } from "./crypto"; import { Handshake } from "./handshake"; +import { MessageNametagBufferSize, MessageNametagLength } from "./messagenametag"; import { NoiseHandshakePatterns } from "./patterns"; -import { MessageNametagBufferSize, MessageNametagLength } from "./payload"; import { NoisePublicKey } from "./publickey"; import { QR } from "./qr"; @@ -51,7 +51,7 @@ describe("Waku Noise Sessions", () => { const qr = new QR(applicationName, applicationVersion, shardId, bobEphemeralKey.publicKey, bobCommittedStaticKey); // Alice deserializes the QR code - const readQR = QR.fromString(qr.toString()); + const readQR = QR.from(qr.toString()); // We check if QR serialization/deserialization works expect(readQR.applicationName).to.be.equals(applicationName);