js-noise/src/pairing.ts

426 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";
2023-05-10 23:23:32 +02:00
import type { IMetaSetter, IReceiver, ISender } 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";
2023-11-20 15:56:52 -04:00
import { commitPublicKey } from "./crypto.js";
2022-12-06 22:53:20 -04:00
import { Handshake, HandshakeResult, HandshakeStepResult, MessageNametagError } from "./handshake.js";
2023-01-06 13:34:32 -04:00
import { MessageNametagLength } from "./messagenametag.js";
import { HandshakePattern, NoiseHandshakePatterns } from "./patterns.js";
2022-12-06 22:53:20 -04:00
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");
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();
export interface EncoderParameters {
ephemeral?: boolean;
metaSetter?: IMetaSetter;
}
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 readonly hsPattern: HandshakePattern;
2022-12-03 09:38:08 -04:00
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
* @param encoderParameters optional parameters for the resulting encoders
2023-01-06 13:34:32 -04:00
*/
2022-12-03 09:38:08 -04:00
constructor(
2023-04-04 01:11:57 +02:00
private sender: ISender,
private responder: IReceiver,
2022-12-03 09:38:08 -04:00
private myStaticKey: KeyPair,
pairingParameters: InitiatorParameters | ResponderParameters,
private myEphemeralKey?: KeyPair,
private readonly encoderParameters: EncoderParameters = {}
2022-12-03 09:38:08 -04:00
) {
this.hsPattern = NoiseHandshakePatterns.Noise_WakuPairing_25519_ChaChaPoly_SHA256;
2022-12-03 09:38:08 -04:00
this.randomFixLenVal = randomBytes(32, rng);
this.myCommittedStaticKey = commitPublicKey(this.hsPattern.hash, this.myStaticKey.publicKey, this.randomFixLenVal);
if (!this.myEphemeralKey) {
this.myEphemeralKey = NoiseHandshakePatterns.Noise_WakuPairing_25519_ChaChaPoly_SHA256.dhKey.generateKeyPair();
}
2022-12-03 09:38:08 -04:00
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: this.hsPattern,
ephemeralKey: this.myEphemeralKey,
2022-12-03 09:38:08 -04:00
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(
messageNametag: Uint8Array,
iterator: AsyncIterator<NoiseHandshakeMessage>
2022-12-03 09:38:08 -04:00
): 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 item = await iterator.next();
if (!item.value) {
throw Error("Received no message");
}
2022-12-03 09:38:08 -04:00
const step = this.handshake.stepHandshake({
readPayloadV2: item.value.payloadV2,
2022-12-03 09:38:08 -04:00
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);
const subscriptionIterator = await this.responder.toSubscriptionIterator(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-04-04 01:02:07 +02:00
await this.sender.send(encoder, {
payload: new Uint8Array(),
});
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());
log("Waiting for authcode confirmation...");
2022-12-06 22:53:20 -04:00
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}
2023-05-10 23:23:32 +02:00
hsStep = await this.executeReadStepWithNextMessage(
this.handshake.hs.toMessageNametag(),
subscriptionIterator.iterator
);
2022-12-03 09:38:08 -04:00
await subscriptionIterator.stop();
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.hsPattern.hash,
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-04-04 01:02:07 +02:00
await this.sender.send(encoder, {
payload: new Uint8Array(),
});
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");
return WakuPairing.getSecureCodec(this.contentTopic, this.handshakeResult, this.encoderParameters);
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);
const subscriptionIterator = await this.responder.toSubscriptionIterator(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.qrMessageNameTag, subscriptionIterator.iterator);
2022-12-03 09:38:08 -04:00
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());
log("Waiting for authcode confirmation...");
2022-12-06 22:53:20 -04:00
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-04-04 01:02:07 +02:00
await this.sender.send(encoder, {
payload: new Uint8Array(),
});
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
2023-05-10 23:23:32 +02:00
hsStep = await this.executeReadStepWithNextMessage(
this.handshake.hs.toMessageNametag(),
subscriptionIterator.iterator
);
2022-12-03 09:38:08 -04:00
await subscriptionIterator.stop();
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
const expectedInitiatorCommittedStaticKey = commitPublicKey(
this.hsPattern.hash,
this.handshake.hs.rs,
hsStep.transportMessage
);
2022-12-03 09:38:08 -04:00
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");
return WakuPairing.getSecureCodec(this.contentTopic, this.handshakeResult, this.encoderParameters);
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
* @param encoderParameters Parameters for the resulting encoder
2023-01-06 13:34:32 -04:00
* @returns an array with [NoiseSecureTransferEncoder, NoiseSecureTransferDecoder]
*/
2022-12-07 15:33:03 -04:00
static getSecureCodec(
contentTopic: string,
hsResult: HandshakeResult,
encoderParameters: EncoderParameters
2022-12-07 15:33:03 -04:00
): [NoiseSecureTransferEncoder, NoiseSecureTransferDecoder] {
const secureEncoder = new NoiseSecureTransferEncoder(
contentTopic,
hsResult,
encoderParameters.ephemeral,
encoderParameters.metaSetter
);
2022-12-07 15:33:03 -04:00
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));
});
}
}