js-noise/src/pairing.ts

419 lines
15 KiB
TypeScript
Raw Normal View History

2022-12-03 09:38:08 -04:00
import { HMACDRBG } from "@stablelib/hmac-drbg";
import { randomBytes } from "@stablelib/random";
import type { IDecoder, IEncoder, IMessage } from "@waku/interfaces";
2022-12-16 16:05:35 -04:00
import debug from "debug";
2022-12-03 09:38:08 -04:00
import { EventEmitter } from "eventemitter3";
import { pEvent } from "p-event";
import { equals as uint8ArrayEquals } from "uint8arrays/equals";
import { KeyPair } from "./@types/keypair.js";
import {
NoiseHandshakeDecoder,
NoiseHandshakeEncoder,
NoiseHandshakeMessage,
NoiseSecureTransferDecoder,
NoiseSecureTransferEncoder,
2022-12-06 22:53:20 -04:00
} from "./codec.js";
import { commitPublicKey, generateX25519KeyPair } from "./crypto.js";
import { Handshake, HandshakeResult, HandshakeStepResult, MessageNametagError } from "./handshake.js";
2023-01-06 13:34:32 -04:00
import { MessageNametagLength } from "./messagenametag.js";
2022-12-06 22:53:20 -04:00
import { NoiseHandshakePatterns } from "./patterns.js";
import { NoisePublicKey } from "./publickey.js";
import { QR } from "./qr.js";
2022-12-03 09:38:08 -04:00
2022-12-16 16:05:35 -04:00
const log = debug("waku:noise:pairing");
2023-01-06 13:34:32 -04:00
/**
* Sender interface that an object must implement so the pairing object can publish noise messages
*/
2022-12-03 09:38:08 -04:00
export interface Sender {
2023-01-06 13:34:32 -04:00
/**
* Publish a message
* @param encoder NoiseHandshakeEncoder encoder to use to encrypt the messages
* @param msg message to broadcast
*/
2023-01-31 00:55:48 +01:00
publish(encoder: IEncoder, msg: IMessage): Promise<void>;
2022-12-03 09:38:08 -04:00
}
2023-01-06 13:34:32 -04:00
/**
* Responder interface than an object must implement so the pairing object can receive noise messages
*/
export interface Responder {
2023-01-06 13:34:32 -04:00
/**
* subscribe to receive the messages from a content topic
* @param decoder Decoder to use to decrypt the NoiseHandshakeMessages
*/
2023-01-31 00:55:48 +01:00
subscribe(decoder: IDecoder<NoiseHandshakeMessage>): Promise<void>;
2022-12-03 09:38:08 -04:00
2023-01-06 13:34:32 -04:00
/**
* 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
*/
2022-12-03 09:38:08 -04:00
nextMessage(contentTopic: string): Promise<NoiseHandshakeMessage>;
2023-01-06 13:34:32 -04:00
/**
* Stop the subscription to the content topic
* @param contentTopic
*/
stop(contentTopic: string): Promise<void>;
2022-12-03 09:38:08 -04:00
}
2022-12-06 22:53:20 -04:00
function delay(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
2022-12-03 09:38:08 -04:00
const rng = new HMACDRBG();
2023-01-06 13:34:32 -04:00
/**
* Initiator parameters used to setup the pairing object
*/
2022-12-03 09:38:08 -04:00
export class InitiatorParameters {
constructor(public readonly qrCode: string, public readonly qrMessageNameTag: Uint8Array) {}
}
2023-01-06 13:34:32 -04:00
/**
* Responder parameters used to setup the pairing object
*/
export class ResponderParameters {
2022-12-03 09:38:08 -04:00
constructor(
public readonly applicationName: string = "waku-noise-sessions",
public readonly applicationVersion: string = "0.1",
public readonly shardId: string = "10"
) {}
}
2023-01-06 13:34:32 -04:00
/**
* Pairing object to setup a noise session
*/
2022-12-03 09:38:08 -04:00
export class WakuPairing {
public readonly contentTopic: string;
private initiator: boolean;
private randomFixLenVal: Uint8Array; // r or s depending on who is sending the message
private handshake: Handshake;
private myCommittedStaticKey: Uint8Array;
private qr: QR;
private qrMessageNameTag: Uint8Array;
private authCode?: string;
private started = false;
2022-12-07 15:33:03 -04:00
private handshakeResult: HandshakeResult | undefined;
2022-12-03 09:38:08 -04:00
private eventEmitter = new EventEmitter();
2023-01-06 13:34:32 -04:00
/**
* Convert a QR into a content topic
* @param qr
* @returns content topic string
*/
2022-12-03 09:38:08 -04:00
private static toContentTopic(qr: QR): string {
return (
"/" + qr.applicationName + "/" + qr.applicationVersion + "/wakunoise/1/sessions_shard-" + qr.shardId + "/proto"
);
}
2023-01-06 13:34:32 -04:00
/**
* @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
*/
2022-12-03 09:38:08 -04:00
constructor(
private sender: Sender,
private responder: Responder,
2022-12-03 09:38:08 -04:00
private myStaticKey: KeyPair,
pairingParameters: InitiatorParameters | ResponderParameters,
2022-12-03 09:38:08 -04:00
private myEphemeralKey: KeyPair = generateX25519KeyPair()
) {
this.randomFixLenVal = randomBytes(32, rng);
this.myCommittedStaticKey = commitPublicKey(this.myStaticKey.publicKey, this.randomFixLenVal);
if (pairingParameters instanceof InitiatorParameters) {
this.initiator = true;
2023-01-06 13:34:32 -04:00
this.qr = QR.from(pairingParameters.qrCode);
2022-12-03 09:38:08 -04:00
this.qrMessageNameTag = pairingParameters.qrMessageNameTag;
} else {
this.initiator = false;
this.qrMessageNameTag = randomBytes(MessageNametagLength, rng);
this.qr = new QR(
pairingParameters.applicationName,
pairingParameters.applicationVersion,
pairingParameters.shardId,
this.myEphemeralKey.publicKey,
this.myCommittedStaticKey
);
}
2023-01-06 13:34:32 -04:00
2022-12-03 09:38:08 -04:00
// We set the contentTopic from the content topic parameters exchanged in the QR
this.contentTopic = WakuPairing.toContentTopic(this.qr);
// Pre-handshake message
// <- eB {H(sB||r), contentTopicParams, messageNametag}
const preMessagePKs = [NoisePublicKey.fromPublicKey(this.qr.ephemeralKey)];
this.handshake = new Handshake({
hsPattern: NoiseHandshakePatterns.WakuPairing,
ephemeralKey: myEphemeralKey,
staticKey: myStaticKey,
prologue: this.qr.toByteArray(),
preMessagePKs,
initiator: this.initiator,
});
}
2023-01-06 13:34:32 -04:00
/**
* Get pairing information (as an InitiatorParameter object)
* @returns InitiatorParameters
*/
2022-12-03 09:38:08 -04:00
public getPairingInfo(): InitiatorParameters {
return new InitiatorParameters(this.qr.toString(), this.qrMessageNameTag);
}
2023-01-06 13:34:32 -04:00
/**
* 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
*/
2022-12-03 09:38:08 -04:00
public async getAuthCode(): Promise<string> {
return new Promise((resolve) => {
if (this.authCode) {
resolve(this.authCode);
} else {
this.eventEmitter.on("authCodeGenerated", (authCode: string) => {
this.authCode = authCode;
resolve(authCode);
});
}
});
}
2023-01-06 13:34:32 -04:00
/**
* 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);
2022-12-03 09:38:08 -04:00
}
private async isAuthCodeConfirmed(): Promise<boolean | undefined> {
// wait for user to confirm or not, or for the whole pairing process to time out
const p1 = pEvent(this.eventEmitter, "confirmAuthCode");
const p2 = pEvent(this.eventEmitter, "pairingTimeout");
return Promise.race<boolean | undefined>([p1, p2]);
}
private async executeReadStepWithNextMessage(
contentTopic: string,
messageNametag: Uint8Array
): Promise<HandshakeStepResult> {
// TODO: create test unit for this function
let stopLoop = false;
this.eventEmitter.once("pairingTimeout", () => {
stopLoop = true;
});
this.eventEmitter.once("pairingComplete", () => {
stopLoop = true;
});
while (!stopLoop) {
try {
const hsMessage = await this.responder.nextMessage(contentTopic);
2022-12-03 09:38:08 -04:00
const step = this.handshake.stepHandshake({
readPayloadV2: hsMessage.payloadV2,
messageNametag,
});
return step;
} catch (err) {
if (err instanceof MessageNametagError) {
2022-12-16 16:05:35 -04:00
log("Unexpected message nametag", err.expectedNametag, err.actualNametag);
2022-12-06 22:53:20 -04:00
} else {
throw err;
2022-12-03 09:38:08 -04:00
}
}
}
throw new Error("could not obtain next message");
}
private async initiatorHandshake(): Promise<[NoiseSecureTransferEncoder, NoiseSecureTransferDecoder]> {
2022-12-06 22:53:20 -04:00
// Subscribe to the contact content topic
const decoder = new NoiseHandshakeDecoder(this.contentTopic);
await this.responder.subscribe(decoder);
2022-12-06 22:53:20 -04:00
2022-12-03 09:38:08 -04:00
// The handshake initiator writes a Waku2 payload v2 containing the handshake message
// and the (encrypted) transport message
// The message is sent with a messageNametag equal to the one received through the QR code
let hsStep = this.handshake.stepHandshake({
transportMessage: this.myCommittedStaticKey,
messageNametag: this.qrMessageNameTag,
});
// We prepare a message from initiator's payload2
// At this point wakuMsg is sent over the Waku network to responder content topic
2022-12-03 09:38:08 -04:00
let encoder = new NoiseHandshakeEncoder(this.contentTopic, hsStep);
2023-01-31 00:55:48 +01:00
await this.sender.publish(encoder, {});
2022-12-03 09:38:08 -04:00
// We generate an authorization code using the handshake state
// this check has to be confirmed with a user interaction, comparing auth codes in both ends
2022-12-06 22:53:20 -04:00
const confirmationPromise = this.isAuthCodeConfirmed();
await delay(100);
2022-12-03 09:38:08 -04:00
this.eventEmitter.emit("authCodeGenerated", this.handshake.genAuthcode());
2022-12-06 22:53:20 -04:00
console.log("Waiting for authcode confirmation...");
const confirmed = await confirmationPromise;
2022-12-03 09:38:08 -04:00
if (!confirmed) {
throw new Error("authcode is not confirmed");
}
// 2nd step
// <- sB, eAsB {r}
hsStep = await this.executeReadStepWithNextMessage(this.contentTopic, this.handshake.hs.toMessageNametag());
await this.responder.stop(this.contentTopic);
2022-12-03 09:38:08 -04:00
if (!this.handshake.hs.rs) throw new Error("invalid handshake state");
// Initiator further checks if responder's commitment opens to responder's static key received
const expectedResponderCommittedStaticKey = commitPublicKey(this.handshake.hs.rs, hsStep.transportMessage);
if (!uint8ArrayEquals(expectedResponderCommittedStaticKey, this.qr.committedStaticKey)) {
throw new Error("expected committed static key does not match the responder actual committed static key");
2022-12-03 09:38:08 -04:00
}
// 3rd step
// -> sA, sAeB, sAsB {s}
// Similarly as in first step, the initiator writes a Waku2 payload containing the handshake message and the (encrypted) transport message
hsStep = this.handshake.stepHandshake({
transportMessage: this.randomFixLenVal,
messageNametag: this.handshake.hs.toMessageNametag(),
});
encoder = new NoiseHandshakeEncoder(this.contentTopic, hsStep);
2023-01-31 00:55:48 +01:00
await this.sender.publish(encoder, {});
2022-12-03 09:38:08 -04:00
// Secure Transfer Phase
2022-12-07 15:33:03 -04:00
this.handshakeResult = this.handshake.finalizeHandshake();
2022-12-03 09:38:08 -04:00
this.eventEmitter.emit("pairingComplete");
2022-12-07 15:33:03 -04:00
return WakuPairing.getSecureCodec(this.contentTopic, this.handshakeResult);
2022-12-03 09:38:08 -04:00
}
private async responderHandshake(): Promise<[NoiseSecureTransferEncoder, NoiseSecureTransferDecoder]> {
2022-12-03 09:38:08 -04:00
// Subscribe to the contact content topic
const decoder = new NoiseHandshakeDecoder(this.contentTopic);
await this.responder.subscribe(decoder);
2022-12-03 09:38:08 -04:00
// the received reads the initiator's payloads, and returns the (decrypted) transport message the initiator sent
// Note that the received verifies if the received payloadV2 has the expected messageNametag set
let hsStep = await this.executeReadStepWithNextMessage(this.contentTopic, this.qrMessageNameTag);
const initiatorCommittedStaticKey = new Uint8Array(hsStep.transportMessage);
2022-12-06 22:53:20 -04:00
const confirmationPromise = this.isAuthCodeConfirmed();
await delay(100);
2022-12-03 09:38:08 -04:00
this.eventEmitter.emit("authCodeGenerated", this.handshake.genAuthcode());
2022-12-06 22:53:20 -04:00
console.log("Waiting for authcode confirmation...");
const confirmed = await confirmationPromise;
2022-12-03 09:38:08 -04:00
if (!confirmed) {
throw new Error("authcode is not confirmed");
}
// 2nd step
// <- sB, eAsB {r}
// Responder writes and returns a payload
2022-12-03 09:38:08 -04:00
hsStep = this.handshake.stepHandshake({
transportMessage: this.randomFixLenVal,
messageNametag: this.handshake.hs.toMessageNametag(),
});
// We prepare a Waku message from responder's payload2
2022-12-03 09:38:08 -04:00
const encoder = new NoiseHandshakeEncoder(this.contentTopic, hsStep);
2023-01-31 00:55:48 +01:00
await this.sender.publish(encoder, {});
2022-12-03 09:38:08 -04:00
// 3rd step
// -> sA, sAeB, sAsB {s}
// The responder reads the initiator's payload sent by the initiator
2022-12-03 09:38:08 -04:00
hsStep = await this.executeReadStepWithNextMessage(this.contentTopic, this.handshake.hs.toMessageNametag());
await this.responder.stop(this.contentTopic);
2022-12-03 09:38:08 -04:00
if (!this.handshake.hs.rs) throw new Error("invalid handshake state");
// The responder further checks if the initiator's commitment opens to the initiator's static key received
2022-12-03 09:38:08 -04:00
const expectedInitiatorCommittedStaticKey = commitPublicKey(this.handshake.hs.rs, hsStep.transportMessage);
if (!uint8ArrayEquals(expectedInitiatorCommittedStaticKey, initiatorCommittedStaticKey)) {
throw new Error("expected committed static key does not match the initiator actual committed static key");
}
// Secure Transfer Phase
2022-12-07 15:33:03 -04:00
this.handshakeResult = this.handshake.finalizeHandshake();
2022-12-03 09:38:08 -04:00
this.eventEmitter.emit("pairingComplete");
2022-12-07 15:33:03 -04:00
return WakuPairing.getSecureCodec(this.contentTopic, this.handshakeResult);
2022-12-03 09:38:08 -04:00
}
2023-01-06 13:34:32 -04:00
/**
* 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]
*/
2022-12-07 15:33:03 -04:00
static getSecureCodec(
contentTopic: string,
hsResult: HandshakeResult
): [NoiseSecureTransferEncoder, NoiseSecureTransferDecoder] {
const secureEncoder = new NoiseSecureTransferEncoder(contentTopic, hsResult);
const secureDecoder = new NoiseSecureTransferDecoder(contentTopic, hsResult);
2022-12-03 09:38:08 -04:00
return [secureEncoder, secureDecoder];
}
2023-01-06 13:34:32 -04:00
/**
* Get handshake result
* @returns result of a successful pairing
*/
2022-12-07 15:33:03 -04:00
public getHandshakeResult(): HandshakeResult {
if (!this.handshakeResult) {
throw new Error("handshake is not complete");
}
return this.handshakeResult;
}
2023-01-06 13:34:32 -04:00
/**
* 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]> {
2022-12-03 09:38:08 -04:00
if (this.started) {
throw new Error("pairing already executed. Create new pairing object");
}
this.started = true;
return new Promise((resolve, reject) => {
2023-01-06 13:34:32 -04:00
// Limit QR exposure to some timeout
2022-12-03 09:38:08 -04:00
const timer = setTimeout(() => {
reject(new Error("pairing has timed out"));
this.eventEmitter.emit("pairingTimeout");
}, timeoutMs);
const handshakeFn = this.initiator ? this.initiatorHandshake : this.responderHandshake;
2022-12-03 09:38:08 -04:00
handshakeFn
.bind(this)()
.then(
(response) => resolve(response),
(err) => reject(err)
2022-12-03 09:38:08 -04:00
)
.finally(() => clearTimeout(timer));
});
}
}