handshake processing

This commit is contained in:
Richard Ramos 2022-11-14 14:30:28 -04:00
parent b1390e0855
commit a0c6fb45ed
No known key found for this signature in database
GPG Key ID: BD36D48BC9FFC88C
5 changed files with 908 additions and 508 deletions

View File

@ -63,7 +63,8 @@
{
"files": ["*.spec.ts", "**/test_utils/*.ts"],
"rules": {
"@typescript-eslint/no-non-null-assertion": "off"
"@typescript-eslint/no-non-null-assertion": "off",
"@typescript-eslint/no-inferrable-types": "off"
}
}
]

View File

@ -1,49 +1,6 @@
import type { Nonce } from "../nonce.js";
import type { bytes, bytes32, uint64 } from "./basic.js";
import type { KeyPair } from "./keypair.js";
import type { bytes } from "./basic.js";
export type Hkdf = [bytes, bytes, bytes];
export interface MessageBuffer {
ne: bytes32;
ns: bytes;
ciphertext: bytes;
}
export interface 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;
}
export interface SymmetricState {
cs: CipherState;
ck: bytes32; // chaining key
h: bytes32; // handshake hash
}
export interface HandshakeState {
ss: SymmetricState;
s: KeyPair;
e?: KeyPair;
rs: bytes32;
re: bytes32;
psk: bytes32;
}
export interface NoiseSession {
hs: HandshakeState;
h?: bytes32;
cs1?: CipherState;
cs2?: CipherState;
mc: uint64;
i: boolean;
}
export interface INoisePayload {
identityKey: bytes;
identitySig: bytes;
data: bytes;
}
// a transport message (for Noise handshakes and ChaChaPoly encryptions)
export type MessageNametag = Uint8Array;

View File

@ -1,501 +1,260 @@
import * as pkcs7 from "pkcs7-padding";
import { bytes32 } from "./@types/basic.js";
import type { KeyPair } from "./@types/keypair.js";
import {
Curve25519KeySize,
dh,
generateX25519KeyPair,
intoCurve25519Key,
} from "./crypto.js";
import { SymmetricState } from "./noise.js";
import {
EmptyPreMessage,
HandshakePattern,
MessageDirection,
NoiseTokens,
PreMessagePattern,
} from "./patterns";
import { NoisePublicKey } from "./publickey.js";
import { bytes32 } from "./@types/basic";
import { KeyPair } from "./@types/keypair";
import { HandshakeState, NoisePaddingBlockSize } from "./handshake_state";
import { CipherState } from "./noise";
import { HandshakePattern, PayloadV2ProtocolIDs } from "./patterns";
import { MessageNametagBuffer, PayloadV2, toMessageNametag } from "./payload";
import { NoisePublicKey } from "./publickey";
// The padding blocksize of a transport message
const NoisePaddingBlockSize = 248;
// Noise state machine
// The Handshake State as in https://noiseprotocol.org/noise.html#the-handshakestate-object
// Contains
// - the local and remote ephemeral/static keys e,s,re,rs (if any)
// - the initiator flag (true if the user creating the state is the handshake initiator, false otherwise)
// - the handshakePattern (containing the handshake protocol name, and (pre)message patterns)
// This object is futher extended from specifications by storing:
// - a message pattern index msgPatternIdx indicating the next handshake message pattern to process
// - the user's preshared psk, if any
export class HandshakeState {
s?: KeyPair;
e?: KeyPair;
rs?: bytes32;
re?: bytes32;
ss: SymmetricState;
initiator: boolean;
handshakePattern: HandshakePattern;
msgPatternIdx: number;
psk: Uint8Array;
// While processing messages patterns, users either:
// - read (decrypt) the other party's (encrypted) transport message
// - write (encrypt) a message, sent through a PayloadV2
// These two intermediate results are stored in the HandshakeStepResult data structure
export class HandshakeStepResult {
payload2: PayloadV2 = new PayloadV2();
transportMessage: Uint8Array = new Uint8Array();
}
constructor(hsPattern: HandshakePattern, psk: Uint8Array) {
// By default the Handshake State initiator flag is set to false
// Will be set to true when the user associated to the handshake state starts an handshake
this.initiator = false;
// When a handshake is complete, the HandhshakeResult will contain the two
// Cipher States used to encrypt/decrypt outbound/inbound messages
// The recipient static key rs and handshake hash values h are stored to address some possible future applications (channel-binding, session management, etc.).
// However, are not required by Noise specifications and are thus optional
export class HandshakeResult {
csOutbound?: CipherState;
csInbound?: CipherState;
// Optional fields:
nametagsInbound: MessageNametagBuffer = new MessageNametagBuffer();
nametagsOutbound: MessageNametagBuffer = new MessageNametagBuffer();
rs: bytes32 = new Uint8Array();
h: bytes32 = new Uint8Array();
}
this.handshakePattern = hsPattern;
this.psk = psk;
export class Handshake {
hs: HandshakeState;
constructor(
hasPattern: HandshakePattern,
ephemeralKey: KeyPair,
staticKey?: KeyPair,
prologue: Uint8Array = new Uint8Array(),
psk: Uint8Array = new Uint8Array(),
preMessagePKs: Array<NoisePublicKey> = [],
initiator = false
) {
this.hs = new HandshakeState(hasPattern, psk);
this.hs.ss.mixHash(prologue);
this.hs.e = ephemeralKey;
this.hs.s = staticKey;
this.hs.psk = psk;
this.hs.msgPatternIdx = 0;
this.hs.initiator = initiator;
this.ss = new SymmetricState(hsPattern);
this.msgPatternIdx = 0;
// We process any eventual handshake pre-message pattern by processing pre-message public keys
this.hs.processPreMessagePatternTokens(preMessagePKs);
}
// Handshake Processing
// Advances 1 step in handshake
// Each user in a handshake alternates writing and reading of handshake messages.
// If the user is writing the handshake message, the transport message (if not empty) and eventually a non-empty message nametag has to be passed to transportMessage and messageNametag and readPayloadV2 can be left to its default value
// It the user is reading the handshake message, the read payload v2 has to be passed to readPayloadV2 and the transportMessage can be left to its default values. Decryption is skipped if the payloadv2 read doesn't have a message nametag equal to messageNametag (empty input nametags are converted to all-0 MessageNametagLength bytes arrays)
stepHandshake(
readPayloadV2: PayloadV2 = new PayloadV2(),
transportMessage: Uint8Array = new Uint8Array(),
messageNametag: Uint8Array = new Uint8Array()
): HandshakeStepResult {
const hsStepResult = new HandshakeStepResult();
// Based on the message handshake direction and if the user is or not the initiator, returns a boolean tuple telling if the user
// has to read or write the next handshake message
getReadingWritingState(direction: MessageDirection): {
reading: boolean;
writing: boolean;
} {
let reading = false;
let writing = false;
if (this.initiator && direction == MessageDirection.r) {
// I'm Alice and direction is ->
reading = false;
writing = true;
} else if (this.initiator && direction == MessageDirection.l) {
// I'm Alice and direction is <-
reading = true;
writing = false;
} else if (!this.initiator && direction == MessageDirection.r) {
// I'm Bob and direction is ->
reading = true;
writing = false;
} else if (!this.initiator && direction == MessageDirection.l) {
// I'm Bob and direction is <-
reading = false;
writing = true;
}
return { reading, writing };
}
// Checks if a pre-message is valid according to Noise specifications
// http://www.noiseprotocol.org/noise.html#handshake-patterns
isValid(msg: Array<PreMessagePattern>): boolean {
let isValid = true;
// Non-empty pre-messages can only have patterns "e", "s", "e,s" in each direction
const allowedPatterns = [
new PreMessagePattern(MessageDirection.r, [NoiseTokens.s]),
new PreMessagePattern(MessageDirection.r, [NoiseTokens.e]),
new PreMessagePattern(MessageDirection.r, [NoiseTokens.e, NoiseTokens.s]),
new PreMessagePattern(MessageDirection.l, [NoiseTokens.s]),
new PreMessagePattern(MessageDirection.l, [NoiseTokens.e]),
new PreMessagePattern(MessageDirection.l, [NoiseTokens.e, NoiseTokens.s]),
];
// We check if pre message patterns are allowed
for (const pattern of msg) {
if (!allowedPatterns.find((x) => x.equals(pattern))) {
isValid = false;
break;
}
// If there are no more message patterns left for processing
// we return an empty HandshakeStepResult
if (
this.hs.msgPatternIdx >
this.hs.handshakePattern.messagePatterns.length - 1
) {
console.debug(
"stepHandshake called more times than the number of message patterns present in handshake"
);
return hsStepResult;
}
return isValid;
}
// We process the next handshake message pattern
// Handshake messages processing procedures
// Processes pre-message patterns
processPreMessagePatternTokens(
inPreMessagePKs: Array<NoisePublicKey> = []
): void {
// I make a copy of the input pre-message public keys, so that I can easily delete processed ones without using iterators/counters
const preMessagePKs = inPreMessagePKs;
// Here we store currently processed pre message public key
let currPK: NoisePublicKey;
// We retrieve the pre-message patterns to process, if any
// If none, there's nothing to do
if (this.handshakePattern.preMessagePatterns == EmptyPreMessage) {
return;
}
// If not empty, we check that pre-message is valid according to Noise specifications
if (!this.isValid(this.handshakePattern.preMessagePatterns)) {
throw "invalid pre-message in handshake";
}
// We iterate over each pattern contained in the pre-message
for (const messagePattern of this.handshakePattern.preMessagePatterns) {
const direction = messagePattern.direction;
const tokens = messagePattern.tokens;
// We get if the user is reading or writing the current pre-message pattern
const { reading, writing } = this.getReadingWritingState(direction);
// We process each message pattern token
for (const token of tokens) {
// We process the pattern token
switch (token) {
case NoiseTokens.e:
// We expect an ephemeral key, so we attempt to read it (next PK to process will always be at index 0 of preMessagePKs)
if (preMessagePKs.length > 0) {
currPK = preMessagePKs[0];
} else {
throw "noise pre-message read e, expected a public key";
}
// If user is reading the "e" token
if (reading) {
console.trace("noise pre-message read e");
// We check if current key is encrypted or not. We assume pre-message public keys are all unencrypted on users' end
if (currPK.flag == 0) {
// Sets re and calls MixHash(re.public_key).
this.re = intoCurve25519Key(currPK.pk);
this.ss.mixHash(this.re);
} else {
throw "noise read e, incorrect encryption flag for pre-message public key";
}
// If user is writing the "e" token
} else if (writing) {
console.trace("noise pre-message write e");
// When writing, the user is sending a public key,
// We check that the public part corresponds to the set local key and we call MixHash(e.public_key).
if (this.e && this.e.publicKey == intoCurve25519Key(currPK.pk)) {
this.ss.mixHash(this.e.publicKey);
} else {
throw "noise pre-message e key doesn't correspond to locally set e key pair";
}
}
// Noise specification: section 9.2
// In non-PSK handshakes, the "e" token in a pre-message pattern or message pattern always results
// in a call to MixHash(e.public_key).
// In a PSK handshake, all of these calls are followed by MixKey(e.public_key).
if (this.handshakePattern.name.indexOf("psk") > -1) {
this.ss.mixKey(currPK.pk);
}
// We delete processed public key
preMessagePKs.shift();
break;
case NoiseTokens.s:
// We expect a static key, so we attempt to read it (next PK to process will always be at index of preMessagePKs)
if (preMessagePKs.length > 0) {
currPK = preMessagePKs[0];
} else {
throw "noise pre-message read s, expected a public key";
}
// If user is reading the "s" token
if (reading) {
console.trace("noise pre-message read s");
// We check if current key is encrypted or not. We assume pre-message public keys are all unencrypted on users' end
if (currPK.flag == 0) {
// Sets re and calls MixHash(re.public_key).
this.rs = intoCurve25519Key(currPK.pk);
this.ss.mixHash(this.rs);
} else {
throw "noise read s, incorrect encryption flag for pre-message public key";
}
// If user is writing the "s" token
} else if (writing) {
console.trace("noise pre-message write s");
// If writing, it means that the user is sending a public key,
// We check that the public part corresponds to the set local key and we call MixHash(s.public_key).
if (this.s && this.s.publicKey == intoCurve25519Key(currPK.pk)) {
this.ss.mixHash(this.s.publicKey);
} else {
throw "noise pre-message s key doesn't correspond to locally set s key pair";
}
}
// Noise specification: section 9.2
// In non-PSK handshakes, the "e" token in a pre-message pattern or message pattern always results
// in a call to MixHash(e.public_key).
// In a PSK handshake, all of these calls are followed by MixKey(e.public_key).
if (this.handshakePattern.name.indexOf("psk") > -1) {
this.ss.mixKey(currPK.pk);
}
// We delete processed public key
preMessagePKs.shift();
break;
default:
throw "invalid Token for pre-message pattern";
}
}
}
}
// This procedure encrypts/decrypts the implicit payload attached at the end of every message pattern
// An optional extraAd to pass extra additional data in encryption/decryption can be set (useful to authenticate messageNametag)
processMessagePatternPayload(
transportMessage: Uint8Array,
extraAd: Uint8Array = new Uint8Array()
): Uint8Array {
let payload: Uint8Array;
// We retrieve current message pattern (direction + tokens) to process
// We get if the user is reading or writing the input handshake message
const direction =
this.handshakePattern.messagePatterns[this.msgPatternIdx].direction;
this.hs.handshakePattern.messagePatterns[this.hs.msgPatternIdx].direction;
const { reading, writing } = this.hs.getReadingWritingState(direction);
// We get if the user is reading or writing the input handshake message
const { reading, writing } = this.getReadingWritingState(direction);
// If we write an answer at this handshake step
if (writing) {
// We initialize a payload v2 and we set proper protocol ID (if supported)
try {
hsStepResult.payload2.protocolId =
PayloadV2ProtocolIDs[
this.hs.handshakePattern.name as keyof typeof PayloadV2ProtocolIDs
];
} catch (err) {
throw "Handshake Pattern not supported";
}
// We decrypt the transportMessage, if any
if (reading) {
payload = this.ss.decryptAndHash(transportMessage, extraAd);
payload = pkcs7.pad(payload, NoisePaddingBlockSize);
} else if (writing) {
payload = pkcs7.unpad(transportMessage);
payload = this.ss.encryptAndHash(payload, extraAd);
// We set the messageNametag and the handshake and transport messages
hsStepResult.payload2.messageNametag = toMessageNametag(messageNametag);
hsStepResult.payload2.handshakeMessage =
this.hs.processMessagePatternTokens();
// We write the payload by passing the messageNametag as extra additional data
hsStepResult.payload2.transportMessage =
this.hs.processMessagePatternPayload(
transportMessage,
hsStepResult.payload2.messageNametag
);
// If we read an answer during this handshake step
} else if (reading) {
// If the read message nametag doesn't match the expected input one we raise an error
if (readPayloadV2.messageNametag != toMessageNametag(messageNametag)) {
throw "The message nametag of the read message doesn't match the expected one";
}
// We process the read public keys and (eventually decrypt) the read transport message
const readHandshakeMessage = readPayloadV2.handshakeMessage;
const readTransportMessage = readPayloadV2.transportMessage;
// Since we only read, nothing meanigful (i.e. public keys) is returned
this.hs.processMessagePatternTokens(readHandshakeMessage);
// We retrieve and store the (decrypted) received transport message by passing the messageNametag as extra additional data
hsStepResult.transportMessage = this.hs.processMessagePatternPayload(
readTransportMessage,
readPayloadV2.messageNametag
);
} else {
throw "undefined state";
throw "Handshake Error: neither writing or reading user";
}
return payload;
// We increase the handshake state message pattern index to progress to next step
this.hs.msgPatternIdx += 1;
return hsStepResult;
}
// We process an input handshake message according to current handshake state and we return the next handshake step's handshake message
processMessagePatternTokens(
inputHandshakeMessage: Array<NoisePublicKey> = []
): Array<NoisePublicKey> {
// We retrieve current message pattern (direction + tokens) to process
const messagePattern =
this.handshakePattern.messagePatterns[this.msgPatternIdx];
const direction = messagePattern.direction;
const tokens = messagePattern.tokens;
// Finalizes the handshake by calling Split and assigning the proper Cipher States to users
finalizeHandshake(): HandshakeResult {
const hsResult = new HandshakeResult();
// We get if the user is reading or writing the input handshake message
const { reading, writing } = this.getReadingWritingState(direction);
// Noise specification, Section 5:
// Processing the final handshake message returns two CipherState objects,
// the first for encrypting transport messages from initiator to responder,
// and the second for messages in the other direction.
// I make a copy of the handshake message so that I can easily delete processed PKs without using iterators/counters
// (Possibly) non-empty if reading
const inHandshakeMessage = inputHandshakeMessage;
// We call Split()
const { cs1, cs2 } = this.hs.ss.split();
// The party's output public keys
// (Possibly) non-empty if writing
const outHandshakeMessage: Array<NoisePublicKey> = [];
// Optional: We derive a secret for the nametag derivation
const { nms1, nms2 } = this.hs.genMessageNametagSecrets();
// In currPK we store the currently processed public key from the handshake message
let currPK: NoisePublicKey;
// We assign the proper Cipher States
if (this.hs.initiator) {
hsResult.csOutbound = cs1;
hsResult.csInbound = cs2;
// and nametags secrets
hsResult.nametagsInbound.secret = nms1;
hsResult.nametagsOutbound.secret = nms2;
} else {
hsResult.csOutbound = cs2;
hsResult.csInbound = cs1;
// and nametags secrets
hsResult.nametagsInbound.secret = nms2;
hsResult.nametagsOutbound.secret = nms1;
}
// We process each message pattern token
for (const token of tokens) {
switch (token) {
case NoiseTokens.e:
// If user is reading the "s" token
if (reading) {
console.trace("noise read e");
// We initialize the message nametags inbound/outbound buffers
hsResult.nametagsInbound.initNametagsBuffer();
hsResult.nametagsOutbound.initNametagsBuffer();
// We expect an ephemeral key, so we attempt to read it (next PK to process will always be at index 0 of preMessagePKs)
if (inHandshakeMessage.length > 0) {
currPK = inHandshakeMessage[0];
} else {
throw "noise read e, expected a public key";
}
// We store the optional fields rs and h
hsResult.rs = this.hs.rs!;
hsResult.h = this.hs.ss.h;
// We check if current key is encrypted or not
// Note: by specification, ephemeral keys should always be unencrypted. But we support encrypted ones.
if (currPK.flag == 0) {
// Unencrypted Public Key
// Sets re and calls MixHash(re.public_key).
this.re = intoCurve25519Key(currPK.pk);
this.ss.mixHash(this.re);
return hsResult;
}
// The following is out of specification: we call decryptAndHash for encrypted ephemeral keys, similarly as happens for (encrypted) static keys
} else if (currPK.flag == 1) {
// Encrypted public key
// Decrypts re, sets re and calls MixHash(re.public_key).
this.re = intoCurve25519Key(this.ss.decryptAndHash(currPK.pk));
} else {
throw "noise read e, incorrect encryption flag for public key";
}
// Noise specification, Section 5:
// Transport messages are then encrypted and decrypted by calling EncryptWithAd()
// and DecryptWithAd() on the relevant CipherState with zero-length associated data.
// If DecryptWithAd() signals an error due to DECRYPT() failure, then the input message is discarded.
// The application may choose to delete the CipherState and terminate the session on such an error,
// or may continue to attempt communications. If EncryptWithAd() or DecryptWithAd() signal an error
// due to nonce exhaustion, then the application must delete the CipherState and terminate the session.
// Noise specification: section 9.2
// In non-PSK handshakes, the "e" token in a pre-message pattern or message pattern always results
// in a call to MixHash(e.public_key).
// In a PSK handshake, all of these calls are followed by MixKey(e.public_key).
if (this.handshakePattern.name.indexOf("psk") > -1) {
this.ss.mixKey(this.re);
}
// Writes an encrypted message using the proper Cipher State
writeMessage(
hsr: HandshakeResult,
transportMessage: Uint8Array,
outboundMessageNametagBuffer: MessageNametagBuffer
): PayloadV2 {
const payload2 = new PayloadV2();
// We delete processed public key
inHandshakeMessage.shift();
// We set the message nametag using the input buffer
payload2.messageNametag = outboundMessageNametagBuffer.pop();
// If user is writing the "e" token
} else if (writing) {
console.trace("noise write e");
// According to 35/WAKU2-NOISE RFC, no Handshake protocol information is sent when exchanging messages
// This correspond to setting protocol-id to 0
payload2.protocolId = 0;
// We pad the transport message
const paddedTransportMessage = pkcs7.pad(
transportMessage,
NoisePaddingBlockSize
);
// Encryption is done with zero-length associated data as per specification
payload2.transportMessage = hsr.csOutbound!.encryptWithAd(
payload2.messageNametag,
paddedTransportMessage
);
// We generate a new ephemeral keypair
this.e = generateX25519KeyPair();
return payload2;
}
// We update the state
this.ss.mixHash(this.e.publicKey);
// Reads an encrypted message using the proper Cipher State
// Decryption is attempted only if the input PayloadV2 has a messageNametag equal to the one expected
readMessage(
hsr: HandshakeResult,
readPayload2: PayloadV2,
inboundMessageNametagBuffer: MessageNametagBuffer
): Uint8Array {
// The output decrypted message
let message = new Uint8Array();
// Noise specification: section 9.2
// In non-PSK handshakes, the "e" token in a pre-message pattern or message pattern always results
// in a call to MixHash(e.public_key).
// In a PSK handshake, all of these calls are followed by MixKey(e.public_key).
if (this.handshakePattern.name.indexOf("psk") > -1) {
this.ss.mixKey(this.e.publicKey);
}
// If the message nametag does not correspond to the nametag expected in the inbound message nametag buffer
// an error is raised (to be handled externally, i.e. re-request lost messages, discard, etc.)
const nametagIsOk = inboundMessageNametagBuffer.checkNametag(
readPayload2.messageNametag
);
if (!nametagIsOk) {
throw "nametag is not ok";
}
// We add the ephemeral public key to the Waku payload
outHandshakeMessage.push(NoisePublicKey.to(this.e.publicKey));
}
break;
case NoiseTokens.s:
// If user is reading the "s" token
if (reading) {
console.trace("noise read s");
// We expect a static key, so we attempt to read it (next PK to process will always be at index 0 of preMessagePKs)
if (inHandshakeMessage.length > 0) {
currPK = inHandshakeMessage[0];
} else {
throw "noise read s, expected a public key";
}
// We check if current key is encrypted or not
if (currPK.flag == 0) {
// Unencrypted Public Key
// Sets re and calls MixHash(re.public_key).
this.rs = intoCurve25519Key(currPK.pk);
this.ss.mixHash(this.rs);
} else if (currPK.flag == 1) {
// Encrypted public key
// Decrypts rs, sets rs and calls MixHash(rs.public_key).
this.rs = intoCurve25519Key(this.ss.decryptAndHash(currPK.pk));
} else {
throw "noise read s, incorrect encryption flag for public key";
}
// We delete processed public key
inHandshakeMessage.shift();
// If user is writing the "s" token
} else if (writing) {
console.trace("noise write s");
// If the local static key is not set (the handshake state was not properly initialized), we raise an error
if (!this.s) {
throw "static key not set";
}
// We encrypt the public part of the static key in case a key is set in the Cipher State
// That is, encS may either be an encrypted or unencrypted static key.
const encS = this.ss.encryptAndHash(this.s.publicKey);
// We add the (encrypted) static public key to the Waku payload
// Note that encS = (Enc(s) || tag) if encryption key is set, otherwise encS = s.
// We distinguish these two cases by checking length of encryption and we set the proper encryption flag
if (encS.length > Curve25519KeySize) {
outHandshakeMessage.push(new NoisePublicKey(1, encS));
} else {
outHandshakeMessage.push(new NoisePublicKey(0, encS));
}
}
break;
case NoiseTokens.psk:
// If user is reading the "psk" token
console.trace("noise psk");
// Calls MixKeyAndHash(psk)
this.ss.mixKeyAndHash(this.psk);
break;
case NoiseTokens.ee:
// If user is reading the "ee" token
console.trace("noise dh ee");
// If local and/or remote ephemeral keys are not set, we raise an error
if (!this.e || !this.re) {
throw "local or remote ephemeral key not set";
}
// Calls MixKey(DH(e, re)).
this.ss.mixKey(dh(this.e.privateKey, this.re));
break;
case NoiseTokens.es:
// If user is reading the "es" token
console.trace("noise dh es");
// We check if keys are correctly set.
// If both present, we call MixKey(DH(e, rs)) if initiator, MixKey(DH(s, re)) if responder.
if (this.initiator) {
if (!this.e || !this.rs) {
throw "local or remote ephemeral/static key not set";
}
this.ss.mixKey(dh(this.e.privateKey, this.rs));
} else {
if (!this.re || !this.s) {
throw "local or remote ephemeral/static key not set";
}
this.ss.mixKey(dh(this.s.privateKey, this.re));
}
break;
case NoiseTokens.se:
// If user is reading the "se" token
console.trace("noise dh se");
// We check if keys are correctly set.
// If both present, call MixKey(DH(s, re)) if initiator, MixKey(DH(e, rs)) if responder.
if (this.initiator) {
if (!this.s || !this.re) {
throw "local or remote ephemeral/static key not set";
}
this.ss.mixKey(dh(this.s.privateKey, this.re));
} else {
if (!this.rs || !this.e) {
throw "local or remote ephemeral/static key not set";
}
this.ss.mixKey(dh(this.e.privateKey, this.rs));
}
break;
case NoiseTokens.ss:
// If user is reading the "ss" token
console.trace("noise dh ss");
// If local and/or remote static keys are not set, we raise an error
if (!this.s || !this.rs) {
throw "local or remote static key not set";
}
// Calls MixKey(DH(s, rs)).
this.ss.mixKey(dh(this.s.privateKey, this.rs));
break;
// At this point the messageNametag matches the expected nametag.
// According to 35/WAKU2-NOISE RFC, no Handshake protocol information is sent when exchanging messages
if (readPayload2.protocolId == 0) {
// On application level we decide to discard messages which fail decryption, without raising an error
try {
// Decryption is done with messageNametag as associated data
const paddedMessage = hsr.csInbound!.decryptWithAd(
readPayload2.messageNametag,
readPayload2.transportMessage
);
// We unpad the decrypted message
message = pkcs7.unpad(paddedMessage);
// The message successfully decrypted, we can delete the first element of the inbound Message Nametag Buffer
inboundMessageNametagBuffer.delete(1);
} catch (err) {
console.debug(
"A read message failed decryption. Returning empty message as plaintext."
);
message = new Uint8Array();
}
}
return outHandshakeMessage;
return message;
}
}

507
src/handshake_state.ts Normal file
View File

@ -0,0 +1,507 @@
import * as pkcs7 from "pkcs7-padding";
import { bytes32 } from "./@types/basic.js";
import type { KeyPair } from "./@types/keypair.js";
import {
Curve25519KeySize,
dh,
generateX25519KeyPair,
getHKDF,
intoCurve25519Key,
} from "./crypto.js";
import { SymmetricState } from "./noise.js";
import {
EmptyPreMessage,
HandshakePattern,
MessageDirection,
NoiseTokens,
PreMessagePattern,
} from "./patterns";
import { NoisePublicKey } from "./publickey.js";
// The padding blocksize of a transport message
export const NoisePaddingBlockSize = 248;
// The Handshake State as in https://noiseprotocol.org/noise.html#the-handshakestate-object
// Contains
// - the local and remote ephemeral/static keys e,s,re,rs (if any)
// - the initiator flag (true if the user creating the state is the handshake initiator, false otherwise)
// - the handshakePattern (containing the handshake protocol name, and (pre)message patterns)
// This object is futher extended from specifications by storing:
// - a message pattern index msgPatternIdx indicating the next handshake message pattern to process
// - the user's preshared psk, if any
export class HandshakeState {
s?: KeyPair;
e?: KeyPair;
rs?: bytes32;
re?: bytes32;
ss: SymmetricState;
initiator: boolean;
handshakePattern: HandshakePattern;
msgPatternIdx: number;
psk: Uint8Array;
constructor(hsPattern: HandshakePattern, psk: Uint8Array) {
// By default the Handshake State initiator flag is set to false
// Will be set to true when the user associated to the handshake state starts an handshake
this.initiator = false;
this.handshakePattern = hsPattern;
this.psk = psk;
this.ss = new SymmetricState(hsPattern);
this.msgPatternIdx = 0;
}
genMessageNametagSecrets(): { nms1: Uint8Array; nms2: Uint8Array } {
const [nms1, nms2] = getHKDF(this.ss.h, new Uint8Array());
return { nms1, nms2 };
}
// 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
// has to read or write the next handshake message
getReadingWritingState(direction: MessageDirection): {
reading: boolean;
writing: boolean;
} {
let reading = false;
let writing = false;
if (this.initiator && direction == MessageDirection.r) {
// I'm Alice and direction is ->
reading = false;
writing = true;
} else if (this.initiator && direction == MessageDirection.l) {
// I'm Alice and direction is <-
reading = true;
writing = false;
} else if (!this.initiator && direction == MessageDirection.r) {
// I'm Bob and direction is ->
reading = true;
writing = false;
} else if (!this.initiator && direction == MessageDirection.l) {
// I'm Bob and direction is <-
reading = false;
writing = true;
}
return { reading, writing };
}
// Checks if a pre-message is valid according to Noise specifications
// http://www.noiseprotocol.org/noise.html#handshake-patterns
isValid(msg: Array<PreMessagePattern>): boolean {
let isValid = true;
// Non-empty pre-messages can only have patterns "e", "s", "e,s" in each direction
const allowedPatterns = [
new PreMessagePattern(MessageDirection.r, [NoiseTokens.s]),
new PreMessagePattern(MessageDirection.r, [NoiseTokens.e]),
new PreMessagePattern(MessageDirection.r, [NoiseTokens.e, NoiseTokens.s]),
new PreMessagePattern(MessageDirection.l, [NoiseTokens.s]),
new PreMessagePattern(MessageDirection.l, [NoiseTokens.e]),
new PreMessagePattern(MessageDirection.l, [NoiseTokens.e, NoiseTokens.s]),
];
// We check if pre message patterns are allowed
for (const pattern of msg) {
if (!allowedPatterns.find((x) => x.equals(pattern))) {
isValid = false;
break;
}
}
return isValid;
}
// Handshake messages processing procedures
// Processes pre-message patterns
processPreMessagePatternTokens(
inPreMessagePKs: Array<NoisePublicKey> = []
): void {
// I make a copy of the input pre-message public keys, so that I can easily delete processed ones without using iterators/counters
const preMessagePKs = inPreMessagePKs;
// Here we store currently processed pre message public key
let currPK: NoisePublicKey;
// We retrieve the pre-message patterns to process, if any
// If none, there's nothing to do
if (this.handshakePattern.preMessagePatterns == EmptyPreMessage) {
return;
}
// If not empty, we check that pre-message is valid according to Noise specifications
if (!this.isValid(this.handshakePattern.preMessagePatterns)) {
throw "invalid pre-message in handshake";
}
// We iterate over each pattern contained in the pre-message
for (const messagePattern of this.handshakePattern.preMessagePatterns) {
const direction = messagePattern.direction;
const tokens = messagePattern.tokens;
// We get if the user is reading or writing the current pre-message pattern
const { reading, writing } = this.getReadingWritingState(direction);
// We process each message pattern token
for (const token of tokens) {
// We process the pattern token
switch (token) {
case NoiseTokens.e:
// We expect an ephemeral key, so we attempt to read it (next PK to process will always be at index 0 of preMessagePKs)
if (preMessagePKs.length > 0) {
currPK = preMessagePKs[0];
} else {
throw "noise pre-message read e, expected a public key";
}
// If user is reading the "e" token
if (reading) {
console.trace("noise pre-message read e");
// We check if current key is encrypted or not. We assume pre-message public keys are all unencrypted on users' end
if (currPK.flag == 0) {
// Sets re and calls MixHash(re.public_key).
this.re = intoCurve25519Key(currPK.pk);
this.ss.mixHash(this.re);
} else {
throw "noise read e, incorrect encryption flag for pre-message public key";
}
// If user is writing the "e" token
} else if (writing) {
console.trace("noise pre-message write e");
// When writing, the user is sending a public key,
// We check that the public part corresponds to the set local key and we call MixHash(e.public_key).
if (this.e && this.e.publicKey == intoCurve25519Key(currPK.pk)) {
this.ss.mixHash(this.e.publicKey);
} else {
throw "noise pre-message e key doesn't correspond to locally set e key pair";
}
}
// Noise specification: section 9.2
// In non-PSK handshakes, the "e" token in a pre-message pattern or message pattern always results
// in a call to MixHash(e.public_key).
// In a PSK handshake, all of these calls are followed by MixKey(e.public_key).
if (this.handshakePattern.name.indexOf("psk") > -1) {
this.ss.mixKey(currPK.pk);
}
// We delete processed public key
preMessagePKs.shift();
break;
case NoiseTokens.s:
// We expect a static key, so we attempt to read it (next PK to process will always be at index of preMessagePKs)
if (preMessagePKs.length > 0) {
currPK = preMessagePKs[0];
} else {
throw "noise pre-message read s, expected a public key";
}
// If user is reading the "s" token
if (reading) {
console.trace("noise pre-message read s");
// We check if current key is encrypted or not. We assume pre-message public keys are all unencrypted on users' end
if (currPK.flag == 0) {
// Sets re and calls MixHash(re.public_key).
this.rs = intoCurve25519Key(currPK.pk);
this.ss.mixHash(this.rs);
} else {
throw "noise read s, incorrect encryption flag for pre-message public key";
}
// If user is writing the "s" token
} else if (writing) {
console.trace("noise pre-message write s");
// If writing, it means that the user is sending a public key,
// We check that the public part corresponds to the set local key and we call MixHash(s.public_key).
if (this.s && this.s.publicKey == intoCurve25519Key(currPK.pk)) {
this.ss.mixHash(this.s.publicKey);
} else {
throw "noise pre-message s key doesn't correspond to locally set s key pair";
}
}
// Noise specification: section 9.2
// In non-PSK handshakes, the "e" token in a pre-message pattern or message pattern always results
// in a call to MixHash(e.public_key).
// In a PSK handshake, all of these calls are followed by MixKey(e.public_key).
if (this.handshakePattern.name.indexOf("psk") > -1) {
this.ss.mixKey(currPK.pk);
}
// We delete processed public key
preMessagePKs.shift();
break;
default:
throw "invalid Token for pre-message pattern";
}
}
}
}
// This procedure encrypts/decrypts the implicit payload attached at the end of every message pattern
// An optional extraAd to pass extra additional data in encryption/decryption can be set (useful to authenticate messageNametag)
processMessagePatternPayload(
transportMessage: Uint8Array,
extraAd: Uint8Array = new Uint8Array()
): Uint8Array {
let payload: Uint8Array;
// We retrieve current message pattern (direction + tokens) to process
const direction =
this.handshakePattern.messagePatterns[this.msgPatternIdx].direction;
// We get if the user is reading or writing the input handshake message
const { reading, writing } = this.getReadingWritingState(direction);
// We decrypt the transportMessage, if any
if (reading) {
payload = this.ss.decryptAndHash(transportMessage, extraAd);
payload = pkcs7.pad(payload, NoisePaddingBlockSize);
} else if (writing) {
payload = pkcs7.unpad(transportMessage);
payload = this.ss.encryptAndHash(payload, extraAd);
} else {
throw "undefined state";
}
return payload;
}
// We process an input handshake message according to current handshake state and we return the next handshake step's handshake message
processMessagePatternTokens(
inputHandshakeMessage: Array<NoisePublicKey> = []
): Array<NoisePublicKey> {
// We retrieve current message pattern (direction + tokens) to process
const messagePattern =
this.handshakePattern.messagePatterns[this.msgPatternIdx];
const direction = messagePattern.direction;
const tokens = messagePattern.tokens;
// We get if the user is reading or writing the input handshake message
const { reading, writing } = this.getReadingWritingState(direction);
// I make a copy of the handshake message so that I can easily delete processed PKs without using iterators/counters
// (Possibly) non-empty if reading
const inHandshakeMessage = inputHandshakeMessage;
// The party's output public keys
// (Possibly) non-empty if writing
const outHandshakeMessage: Array<NoisePublicKey> = [];
// In currPK we store the currently processed public key from the handshake message
let currPK: NoisePublicKey;
// We process each message pattern token
for (const token of tokens) {
switch (token) {
case NoiseTokens.e:
// If user is reading the "s" token
if (reading) {
console.trace("noise read e");
// We expect an ephemeral key, so we attempt to read it (next PK to process will always be at index 0 of preMessagePKs)
if (inHandshakeMessage.length > 0) {
currPK = inHandshakeMessage[0];
} else {
throw "noise read e, expected a public key";
}
// We check if current key is encrypted or not
// Note: by specification, ephemeral keys should always be unencrypted. But we support encrypted ones.
if (currPK.flag == 0) {
// Unencrypted Public Key
// Sets re and calls MixHash(re.public_key).
this.re = intoCurve25519Key(currPK.pk);
this.ss.mixHash(this.re);
// The following is out of specification: we call decryptAndHash for encrypted ephemeral keys, similarly as happens for (encrypted) static keys
} else if (currPK.flag == 1) {
// Encrypted public key
// Decrypts re, sets re and calls MixHash(re.public_key).
this.re = intoCurve25519Key(this.ss.decryptAndHash(currPK.pk));
} else {
throw "noise read e, incorrect encryption flag for public key";
}
// Noise specification: section 9.2
// In non-PSK handshakes, the "e" token in a pre-message pattern or message pattern always results
// in a call to MixHash(e.public_key).
// In a PSK handshake, all of these calls are followed by MixKey(e.public_key).
if (this.handshakePattern.name.indexOf("psk") > -1) {
this.ss.mixKey(this.re);
}
// We delete processed public key
inHandshakeMessage.shift();
// If user is writing the "e" token
} else if (writing) {
console.trace("noise write e");
// We generate a new ephemeral keypair
this.e = generateX25519KeyPair();
// We update the state
this.ss.mixHash(this.e.publicKey);
// Noise specification: section 9.2
// In non-PSK handshakes, the "e" token in a pre-message pattern or message pattern always results
// in a call to MixHash(e.public_key).
// In a PSK handshake, all of these calls are followed by MixKey(e.public_key).
if (this.handshakePattern.name.indexOf("psk") > -1) {
this.ss.mixKey(this.e.publicKey);
}
// We add the ephemeral public key to the Waku payload
outHandshakeMessage.push(NoisePublicKey.to(this.e.publicKey));
}
break;
case NoiseTokens.s:
// If user is reading the "s" token
if (reading) {
console.trace("noise read s");
// We expect a static key, so we attempt to read it (next PK to process will always be at index 0 of preMessagePKs)
if (inHandshakeMessage.length > 0) {
currPK = inHandshakeMessage[0];
} else {
throw "noise read s, expected a public key";
}
// We check if current key is encrypted or not
if (currPK.flag == 0) {
// Unencrypted Public Key
// Sets re and calls MixHash(re.public_key).
this.rs = intoCurve25519Key(currPK.pk);
this.ss.mixHash(this.rs);
} else if (currPK.flag == 1) {
// Encrypted public key
// Decrypts rs, sets rs and calls MixHash(rs.public_key).
this.rs = intoCurve25519Key(this.ss.decryptAndHash(currPK.pk));
} else {
throw "noise read s, incorrect encryption flag for public key";
}
// We delete processed public key
inHandshakeMessage.shift();
// If user is writing the "s" token
} else if (writing) {
console.trace("noise write s");
// If the local static key is not set (the handshake state was not properly initialized), we raise an error
if (!this.s) {
throw "static key not set";
}
// We encrypt the public part of the static key in case a key is set in the Cipher State
// That is, encS may either be an encrypted or unencrypted static key.
const encS = this.ss.encryptAndHash(this.s.publicKey);
// We add the (encrypted) static public key to the Waku payload
// Note that encS = (Enc(s) || tag) if encryption key is set, otherwise encS = s.
// We distinguish these two cases by checking length of encryption and we set the proper encryption flag
if (encS.length > Curve25519KeySize) {
outHandshakeMessage.push(new NoisePublicKey(1, encS));
} else {
outHandshakeMessage.push(new NoisePublicKey(0, encS));
}
}
break;
case NoiseTokens.psk:
// If user is reading the "psk" token
console.trace("noise psk");
// Calls MixKeyAndHash(psk)
this.ss.mixKeyAndHash(this.psk);
break;
case NoiseTokens.ee:
// If user is reading the "ee" token
console.trace("noise dh ee");
// If local and/or remote ephemeral keys are not set, we raise an error
if (!this.e || !this.re) {
throw "local or remote ephemeral key not set";
}
// Calls MixKey(DH(e, re)).
this.ss.mixKey(dh(this.e.privateKey, this.re));
break;
case NoiseTokens.es:
// If user is reading the "es" token
console.trace("noise dh es");
// We check if keys are correctly set.
// If both present, we call MixKey(DH(e, rs)) if initiator, MixKey(DH(s, re)) if responder.
if (this.initiator) {
if (!this.e || !this.rs) {
throw "local or remote ephemeral/static key not set";
}
this.ss.mixKey(dh(this.e.privateKey, this.rs));
} else {
if (!this.re || !this.s) {
throw "local or remote ephemeral/static key not set";
}
this.ss.mixKey(dh(this.s.privateKey, this.re));
}
break;
case NoiseTokens.se:
// If user is reading the "se" token
console.trace("noise dh se");
// We check if keys are correctly set.
// If both present, call MixKey(DH(s, re)) if initiator, MixKey(DH(e, rs)) if responder.
if (this.initiator) {
if (!this.s || !this.re) {
throw "local or remote ephemeral/static key not set";
}
this.ss.mixKey(dh(this.s.privateKey, this.re));
} else {
if (!this.rs || !this.e) {
throw "local or remote ephemeral/static key not set";
}
this.ss.mixKey(dh(this.e.privateKey, this.rs));
}
break;
case NoiseTokens.ss:
// If user is reading the "ss" token
console.trace("noise dh ss");
// If local and/or remote static keys are not set, we raise an error
if (!this.s || !this.rs) {
throw "local or remote static key not set";
}
// Calls MixKey(DH(s, rs)).
this.ss.mixKey(dh(this.s.privateKey, this.rs));
break;
}
}
return outHandshakeMessage;
}
}

176
src/payload.ts Normal file
View File

@ -0,0 +1,176 @@
// 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";
import { hashSHA256 } from "./crypto";
import { NoisePublicKey } from "./publickey";
const MessageNametagLength = 16;
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<MessageNametag> = new Array<MessageNametag>(
MessageNametagBufferSize
);
counter = 0;
secret?: Uint8Array;
// 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<MessageNametag>(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 = 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.error("Message nametag not found in buffer");
return false;
} else if (index > 0) {
console.error(
"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;
}
// 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
for (let i = 0; i < n; i++) {
const first = this.buffer.shift()!;
this.buffer.push(first);
}
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");
}
}
}
export class PayloadV2 {
messageNametag: MessageNametag;
protocolId: number;
handshakeMessage: Array<NoisePublicKey>;
transportMessage: Uint8Array;
constructor(
messageNametag?: MessageNametag,
protocolId?: number,
handshakeMessage?: Array<NoisePublicKey>,
transportMessage?: Uint8Array
) {
this.messageNametag = messageNametag
? messageNametag
: new Uint8Array(MessageNametagLength);
this.protocolId = protocolId ? protocolId : 0;
this.handshakeMessage = handshakeMessage ? handshakeMessage : [];
this.transportMessage = transportMessage
? transportMessage
: new Uint8Array();
}
}