js-noise/src/pairing.ts

342 lines
12 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 { EventEmitter } from "eventemitter3";
import { Decoder, Encoder, Message } from "js-waku/lib/interfaces";
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";
import { NoiseHandshakePatterns } from "./patterns.js";
import { MessageNametagLength } from "./payload.js";
import { NoisePublicKey } from "./publickey.js";
import { QR } from "./qr.js";
2022-12-03 09:38:08 -04:00
export interface Sender {
publish(encoder: Encoder, msg: Message): Promise<void>;
}
export interface Responder {
2022-12-06 22:53:20 -04:00
subscribe(decoder: Decoder<NoiseHandshakeMessage>): Promise<void>;
2022-12-03 09:38:08 -04:00
// 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)
nextMessage(contentTopic: string): Promise<NoiseHandshakeMessage>;
// this should stop the subscription
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();
export class InitiatorParameters {
constructor(public readonly qrCode: string, public readonly qrMessageNameTag: Uint8Array) {}
}
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"
) {}
}
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();
private static toContentTopic(qr: QR): string {
return (
"/" + qr.applicationName + "/" + qr.applicationVersion + "/wakunoise/1/sessions_shard-" + qr.shardId + "/proto"
);
}
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;
this.qr = QR.fromString(pairingParameters.qrCode);
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
);
}
// 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,
});
}
public getPairingInfo(): InitiatorParameters {
return new InitiatorParameters(this.qr.toString(), this.qrMessageNameTag);
}
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);
});
}
});
}
public validateAuthCode(confirmed: boolean): void {
this.eventEmitter.emit("confirmAuthCode", confirmed);
}
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-06 22:53:20 -04:00
console.debug("Unexpected message nametag", err.expectedNametag, err.actualNametag);
} 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);
await this.sender.publish(encoder, {});
// 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);
await this.sender.publish(encoder, {});
// 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);
await this.sender.publish(encoder, {});
// 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
}
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];
}
2022-12-07 15:33:03 -04:00
public getHandshakeResult(): HandshakeResult {
if (!this.handshakeResult) {
throw new Error("handshake is not complete");
}
return this.handshakeResult;
}
2022-12-03 09:38:08 -04:00
async execute(timeoutMs = 30000): 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
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));
});
}
}