diff --git a/src/crypto.ts b/src/crypto.ts index a59f83b..7a02439 100644 --- a/src/crypto.ts +++ b/src/crypto.ts @@ -1,13 +1,15 @@ -import { ChaCha20Poly1305 } from "@stablelib/chacha20poly1305"; +import { ChaCha20Poly1305, TAG_LENGTH } from "@stablelib/chacha20poly1305"; import { 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 { KeyPair } from "./@types/keypair.js"; export const Curve25519KeySize = x25519.PUBLIC_KEY_LENGTH; +export const ChachaPolyTagLen = TAG_LENGTH; export function hashSHA256(data: Uint8Array): Uint8Array { return hash(data); @@ -86,3 +88,8 @@ export function dh(privateKey: bytes32, publicKey: bytes32): bytes32 { return new Uint8Array(32); } } + +// Commits a public key pk for randomness r as H(pk || s) +export function commitPublicKey(publicKey: bytes32, r: Uint8Array): bytes32 { + return hashSHA256(uint8ArrayConcat([publicKey, r])); +} diff --git a/src/handshake_state.ts b/src/handshake_state.ts index c1be0aa..1f6d0a7 100644 --- a/src/handshake_state.ts +++ b/src/handshake_state.ts @@ -2,6 +2,7 @@ import * as pkcs7 from "pkcs7-padding"; 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 { SymmetricState } from "./noise.js"; @@ -100,6 +101,13 @@ export class HandshakeState { 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()); + return output; + } + // Handshake Processing // Based on the message handshake direction and if the user is or not the initiator, returns a boolean tuple telling if the user diff --git a/src/index.spec.ts b/src/index.spec.ts index c260948..e0f2c5b 100644 --- a/src/index.spec.ts +++ b/src/index.spec.ts @@ -8,7 +8,7 @@ import { Handshake, HandshakeStepResult } from "./handshake"; import { CipherState, createEmptyKey, SymmetricState } from "./noise"; import { MAX_NONCE, Nonce } from "./nonce"; import { NoiseHandshakePatterns } from "./patterns"; -import { MessageNametagBuffer } from "./payload"; +import { MessageNametagBuffer, MessageNametagLength, PayloadV2 } from "./payload"; import { ChaChaPolyCipherState, NoisePublicKey } from "./publickey"; function randomCipherState(rng: HMACDRBG, nonce: number = 0): CipherState { @@ -34,6 +34,14 @@ function randomNoisePublicKey(): NoisePublicKey { return new NoisePublicKey(0, keypair.publicKey); } +function randomPayloadV2(rng: HMACDRBG): PayloadV2 { + const messageNametag = randomBytes(MessageNametagLength, rng); + const protocolId = Math.floor(Math.random() * 255); + const handshakeMessage = [randomNoisePublicKey(), randomNoisePublicKey(), randomNoisePublicKey()]; + const transportMessage = randomBytes(128); + return new PayloadV2(messageNametag, protocolId, handshakeMessage, transportMessage); +} + describe("js-noise", () => { const rng = new HMACDRBG(); @@ -119,6 +127,13 @@ describe("js-noise", () => { expect(noisePublicKey.equals(decryptedPK)).to.be.true; }); + it("PayloadV2: serialize/deserialize PayloadV2 to byte sequence", function () { + const payload2 = randomPayloadV2(rng); + const serializedPayload = payload2.serialize(); + const deserializedPayload = PayloadV2.deserialize(serializedPayload); + expect(deserializedPayload.equals(payload2)).to.be.true; + }); + it("Noise State Machine: Diffie-Hellman operation", function () { const aliceKey = generateX25519KeyPair(); const bobKey = generateX25519KeyPair(); diff --git a/src/payload.ts b/src/payload.ts index 1c2805e..9322d43 100644 --- a/src/payload.ts +++ b/src/payload.ts @@ -6,49 +6,18 @@ import { concat as uint8ArrayConcat } from "uint8arrays/concat"; import { equals as uint8ArrayEquals } from "uint8arrays/equals"; import { MessageNametag } from "./@types/handshake"; -import { hashSHA256 } from "./crypto"; +import { ChachaPolyTagLen, Curve25519KeySize, hashSHA256 } from "./crypto"; import { NoisePublicKey } from "./publickey"; +import { readUIntLE, writeUIntLE } from "./utils"; -const MessageNametagLength = 16; -const MessageNametagBufferSize = 50; +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); } -// Adapted from https://github.com/feross/buffer - -function checkInt(buf: Uint8Array, value: number, offset: number, ext: number, max: number, min: number): void { - if (value > max || value < min) throw new RangeError('"value" argument is out of bounds'); - if (offset + ext > buf.length) throw new RangeError("Index out of range"); -} - -const writeUIntLE = function writeUIntLE( - buf: Uint8Array, - value: number, - offset: number, - byteLength: number, - noAssert?: boolean -): Uint8Array { - value = +value; - offset = offset >>> 0; - byteLength = byteLength >>> 0; - if (!noAssert) { - const maxBytes = Math.pow(2, 8 * byteLength) - 1; - checkInt(buf, value, offset, byteLength, maxBytes, 0); - } - - let mul = 1; - let i = 0; - buf[offset] = value & 0xff; - while (++i < byteLength && (mul *= 0x100)) { - buf[offset + i] = (value / mul) & 0xff; - } - - return buf; -}; - export class MessageNametagBuffer { buffer: Array = new Array(MessageNametagBufferSize); counter = 0; @@ -179,4 +148,116 @@ export class PayloadV2 { 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/ + serialize(): Uint8Array { + // We collect public keys contained in the handshake message + + // According to https://rfc.vac.dev/spec/35/, the maximum size for the handshake message is 256 bytes, that is + // the handshake message length can be represented with 1 byte only. (its length can be stored in 1 byte) + // However, to ease public keys length addition operation, we declare it as int and later cast to uit8 + let serializedHandshakeMessageLen = 0; + // This variables will store the concatenation of the serializations of all public keys in the handshake message + let serializedHandshakeMessage = new Uint8Array(); + + // For each public key in the handshake message + for (const pk of this.handshakeMessage) { + // We serialize the public key + const serializedPk = pk.serialize(); + // We sum its serialized length to the total + serializedHandshakeMessageLen += serializedPk.length; + // We add its serialization to the concatenation of all serialized public keys in the handshake message + serializedHandshakeMessage = uint8ArrayConcat([serializedHandshakeMessage, serializedPk]); + // If we are processing more than 256 byte, we return an error + if (serializedHandshakeMessageLen > 255) { + console.debug("PayloadV2 malformed: too many public keys contained in the handshake message"); + throw new Error("too many public keys in handshake message"); + } + } + + // The output payload as in https://rfc.vac.dev/spec/35/. We concatenate all the PayloadV2 fields as + // payload = ( protocolId || serializedHandshakeMessageLen || serializedHandshakeMessage || transportMessageLen || transportMessage) + + // We concatenate all the data + // The protocol ID (1 byte) and handshake message length (1 byte) can be directly casted to byte to allow direct copy to the payload byte sequence + const payload = uint8ArrayConcat([ + this.messageNametag, + new Uint8Array([this.protocolId]), + new Uint8Array([serializedHandshakeMessageLen]), + serializedHandshakeMessage, + // The transport message length is converted from uint64 to bytes in Little-Endian + writeUIntLE(new Uint8Array(8), this.transportMessage.length, 0, 8), + this.transportMessage, + ]); + + 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) + static deserialize(payload: Uint8Array): PayloadV2 { + // i is the read input buffer position index + let i = 0; + + // We start by reading the messageNametag + const messageNametag = new Uint8Array(MessageNametagLength); + for (let j = 0; j < MessageNametagLength; j++) { + messageNametag[j] = payload[i + j]; + } + i += MessageNametagLength; + + // We read the Protocol ID + // TODO: when the list of supported protocol ID is defined, check if read protocol ID is supported + const protocolId = payload[i]; + i++; + + // We read the Handshake Message length (1 byte) + const handshakeMessageLen = payload[i]; + if (handshakeMessageLen > 255) { + console.debug("payload malformed: too many public keys contained in the handshake message"); + throw new Error("too many public keys in handshake message"); + } + + i++; + + // We now read for handshakeMessageLen bytes the buffer and we deserialize each (encrypted/unencrypted) public key read + // In handshakeMessage we accumulate the read deserialized Noise Public keys + const handshakeMessage = new Array(); + let written = 0; + + // We read the buffer until handshakeMessageLen are read + while (written != handshakeMessageLen) { + // We obtain the current Noise Public key encryption flag + const flag = payload[i]; + // If the key is unencrypted, we only read the X coordinate of the EC public key and we deserialize into a Noise Public Key + if (flag === 0) { + const pkLen = 1 + Curve25519KeySize; + handshakeMessage.push(NoisePublicKey.deserialize(payload.subarray(i, i + pkLen))); + i += pkLen; + written += pkLen; + // If the key is encrypted, we only read the encrypted X coordinate and the authorization tag, and we deserialize into a Noise Public Key + } else if (flag === 1) { + const pkLen = 1 + Curve25519KeySize + ChachaPolyTagLen; + handshakeMessage.push(NoisePublicKey.deserialize(payload.subarray(i, i + pkLen))); + i += pkLen; + written += pkLen; + } else { + throw new Error("invalid flag for Noise public key"); + } + } + + // We read the transport message length (8 bytes) and we convert to uint64 in Little Endian + const transportMessageLen = readUIntLE(payload, i, i + 8 - 1); + i += 8; + + // We read the transport message (handshakeMessage bytes) + const transportMessage = payload.subarray(i, i + transportMessageLen); + i += transportMessageLen; + + return new PayloadV2(messageNametag, protocolId, handshakeMessage, transportMessage); + } } diff --git a/src/utils.ts b/src/utils.ts new file mode 100644 index 0000000..4691fab --- /dev/null +++ b/src/utils.ts @@ -0,0 +1,92 @@ +// Adapted from https://github.com/feross/buffer + +import { bytes32 } from "./@types/basic"; + +function checkInt(buf: Uint8Array, value: number, offset: number, ext: number, max: number, min: number): void { + if (value > max || value < min) throw new RangeError('"value" argument is out of bounds'); + if (offset + ext > buf.length) throw new RangeError("Index out of range"); +} + +export function writeUIntLE( + buf: Uint8Array, + value: number, + offset: number, + byteLength: number, + noAssert?: boolean +): Uint8Array { + value = +value; + offset = offset >>> 0; + byteLength = byteLength >>> 0; + if (!noAssert) { + const maxBytes = Math.pow(2, 8 * byteLength) - 1; + checkInt(buf, value, offset, byteLength, maxBytes, 0); + } + + let mul = 1; + let i = 0; + buf[offset] = value & 0xff; + while (++i < byteLength && (mul *= 0x100)) { + buf[offset + i] = (value / mul) & 0xff; + } + + return buf; +} + +function checkOffset(offset: number, ext: number, length: number): void { + if (offset % 1 !== 0 || offset < 0) throw new RangeError("offset is not uint"); + if (offset + ext > length) throw new RangeError("Trying to access beyond buffer length"); +} + +export function readUIntLE(buf: Uint8Array, offset: number, byteLength: number, noAssert?: boolean): number { + offset = offset >>> 0; + byteLength = byteLength >>> 0; + if (!noAssert) checkOffset(offset, byteLength, buf.length); + + let val = buf[offset]; + let mul = 1; + let i = 0; + while (++i < byteLength && (mul *= 0x100)) { + val += buf[offset + i] * mul; + } + + return val; +} + +// Serializes input parameters to a base64 string for exposure through QR code (used by WakuPairing) +export function toQr( + applicationName: string, + applicationVersion: string, + shardId: string, + ephemeralKey: bytes32, + committedStaticKey: bytes32 +): string { + const decoder = new TextDecoder("utf8"); + let qr = window.btoa(applicationName) + ":"; + qr += window.btoa(applicationVersion) + ":"; + qr += window.btoa(shardId) + ":"; + qr += window.btoa(decoder.decode(ephemeralKey)) + ":"; + qr += window.btoa(decoder.decode(committedStaticKey)); + return qr; +} + +// Deserializes input string in base64 to the corresponding (applicationName, applicationVersion, shardId, ephemeralKey, committedStaticKey) +export function fromQr(qr: string): { + applicationName: string; + applicationVersion: string; + shardId: string; + ephemeralKey: bytes32; + committedStaticKey: bytes32; +} { + const values = qr.split(":"); + + if (values.length != 5) throw new Error("invalid qr string"); + + const encoder = new TextEncoder(); + const applicationName = window.atob(values[0]); + const applicationVersion = window.atob(values[1]); + const shardId = window.atob(values[2]); + const ephemeralKey = encoder.encode(window.atob(values[3])); + const committedStaticKey = encoder.encode(window.atob(values[4])); + + return { applicationName, applicationVersion, shardId, ephemeralKey, committedStaticKey }; +}