mirror of
https://github.com/waku-org/js-noise.git
synced 2025-02-25 01:18:22 +00:00
276 lines
10 KiB
TypeScript
276 lines
10 KiB
TypeScript
import debug from "debug";
|
|
import { fromString as uint8ArrayFromString } from "uint8arrays";
|
|
import { concat as uint8ArrayConcat } from "uint8arrays/concat";
|
|
import { equals as uint8ArrayEquals } from "uint8arrays/equals";
|
|
|
|
import type { bytes32 } from "./@types/basic.js";
|
|
import { chaCha20Poly1305Decrypt, chaCha20Poly1305Encrypt, getHKDF, hashSHA256 } from "./crypto.js";
|
|
import { Nonce } from "./nonce.js";
|
|
import { HandshakePattern } from "./patterns.js";
|
|
|
|
const log = debug("waku:noise:handshake-state");
|
|
|
|
// Waku Noise Protocols for Waku Payload Encryption
|
|
// Noise module implementing the Noise State Objects and ChaChaPoly encryption/decryption primitives
|
|
// See spec for more details:
|
|
// https://github.com/vacp2p/rfc/tree/master/content/docs/rfcs/35
|
|
//
|
|
// Implementation partially inspired by noise-libp2p and js-libp2p-noise
|
|
// https://github.com/status-im/nim-libp2p/blob/master/libp2p/protocols/secure/noise.nim
|
|
// https://github.com/ChainSafe/js-libp2p-noise
|
|
|
|
/*
|
|
# Noise state machine primitives
|
|
|
|
# Overview :
|
|
# - Alice and Bob process (i.e. read and write, based on their role) each token appearing in a handshake pattern, consisting of pre-message and message patterns;
|
|
# - Both users initialize and update according to processed tokens a Handshake State, a Symmetric State and a Cipher State;
|
|
# - A preshared key psk is processed by calling MixKeyAndHash(psk);
|
|
# - When an ephemeral public key e is read or written, the handshake hash value h is updated by calling mixHash(e); If the handshake expects a psk, MixKey(e) is further called
|
|
# - When an encrypted static public key s or a payload message m is read, it is decrypted with decryptAndHash;
|
|
# - When a static public key s or a payload message is written, it is encrypted with encryptAndHash;
|
|
# - When any Diffie-Hellman token ee, es, se, ss is read or written, the chaining key ck is updated by calling MixKey on the computed secret;
|
|
# - If all tokens are processed, users compute two new Cipher States by calling Split;
|
|
# - The two Cipher States obtained from Split are used to encrypt/decrypt outbound/inbound messages.
|
|
|
|
#################################
|
|
# Cipher State Primitives
|
|
#################################
|
|
*/
|
|
|
|
export function createEmptyKey(): bytes32 {
|
|
return new Uint8Array(32);
|
|
}
|
|
|
|
export function isEmptyKey(k: bytes32): boolean {
|
|
const emptyKey = createEmptyKey();
|
|
return uint8ArrayEquals(emptyKey, k);
|
|
}
|
|
|
|
// The Cipher State as in https://noiseprotocol.org/noise.html#the-cipherstate-object
|
|
// Contains an encryption key k and a nonce n (used in Noise as a counter)
|
|
export class 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;
|
|
|
|
constructor(k: bytes32 = createEmptyKey(), n = new Nonce()) {
|
|
this.k = k;
|
|
this.n = n;
|
|
}
|
|
|
|
clone(): CipherState {
|
|
return new CipherState(new Uint8Array(this.k), new Nonce(this.n.getUint64()));
|
|
}
|
|
|
|
equals(b: CipherState): boolean {
|
|
return uint8ArrayEquals(this.k, b.getKey()) && this.n.getUint64() == b.getNonce().getUint64();
|
|
}
|
|
|
|
// Checks if a Cipher State has an encryption key set
|
|
protected hasKey(): boolean {
|
|
return !isEmptyKey(this.k);
|
|
}
|
|
|
|
// Encrypts a plaintext using key material in a Noise Cipher State
|
|
// The CipherState is updated increasing the nonce (used as a counter in Noise) by one
|
|
encryptWithAd(ad: Uint8Array, plaintext: Uint8Array): Uint8Array {
|
|
this.n.assertValue();
|
|
|
|
let ciphertext = new Uint8Array();
|
|
|
|
if (this.hasKey()) {
|
|
// If an encryption key is set in the Cipher state, we proceed with encryption
|
|
ciphertext = chaCha20Poly1305Encrypt(plaintext, this.n.getBytes(), ad, this.k);
|
|
this.n.increment();
|
|
this.n.assertValue();
|
|
|
|
log("encryptWithAd", ciphertext, this.n.getUint64() - 1);
|
|
} else {
|
|
// Otherwise we return the input plaintext according to specification http://www.noiseprotocol.org/noise.html#the-cipherstate-object
|
|
ciphertext = plaintext;
|
|
log("encryptWithAd called with no encryption key set. Returning plaintext.");
|
|
}
|
|
|
|
return ciphertext;
|
|
}
|
|
|
|
// Decrypts a ciphertext using key material in a Noise Cipher State
|
|
// The CipherState is updated increasing the nonce (used as a counter in Noise) by one
|
|
decryptWithAd(ad: Uint8Array, ciphertext: Uint8Array): Uint8Array {
|
|
this.n.assertValue();
|
|
|
|
if (this.hasKey()) {
|
|
const plaintext = chaCha20Poly1305Decrypt(ciphertext, this.n.getBytes(), ad, this.k);
|
|
if (!plaintext) {
|
|
throw new Error("decryptWithAd failed");
|
|
}
|
|
|
|
this.n.increment();
|
|
this.n.assertValue();
|
|
|
|
return plaintext;
|
|
} else {
|
|
// Otherwise we return the input ciphertext according to specification
|
|
// http://www.noiseprotocol.org/noise.html#the-cipherstate-object
|
|
log("decryptWithAd called with no encryption key set. Returning ciphertext.");
|
|
return ciphertext;
|
|
}
|
|
}
|
|
|
|
// Sets the nonce of a Cipher State
|
|
setNonce(nonce: Nonce): void {
|
|
this.n = nonce;
|
|
}
|
|
|
|
// Sets the key of a Cipher State
|
|
setCipherStateKey(key: bytes32): void {
|
|
this.k = key;
|
|
}
|
|
|
|
// Gets the key of a Cipher State
|
|
getKey(): bytes32 {
|
|
return this.k;
|
|
}
|
|
|
|
// Gets the nonce of a Cipher State
|
|
getNonce(): Nonce {
|
|
return this.n;
|
|
}
|
|
}
|
|
|
|
function hashProtocol(name: string): Uint8Array {
|
|
// If protocol_name is less than or equal to HASHLEN bytes in length,
|
|
// sets h equal to protocol_name with zero bytes appended to make HASHLEN bytes.
|
|
// Otherwise sets h = HASH(protocol_name).
|
|
const protocolName = uint8ArrayFromString(name, "utf-8");
|
|
|
|
if (protocolName.length <= 32) {
|
|
const h = new Uint8Array(32);
|
|
h.set(protocolName);
|
|
return h;
|
|
} else {
|
|
return hashSHA256(protocolName);
|
|
}
|
|
}
|
|
|
|
// The Symmetric State as in https://noiseprotocol.org/noise.html#the-symmetricstate-object
|
|
// Contains a Cipher State cs, the chaining key ck and the handshake hash value h
|
|
export class SymmetricState {
|
|
cs: CipherState;
|
|
h: bytes32; // handshake hash
|
|
private ck: bytes32; // chaining key
|
|
|
|
constructor(private readonly hsPattern: HandshakePattern) {
|
|
this.h = hashProtocol(hsPattern.name);
|
|
this.ck = this.h;
|
|
this.cs = new CipherState();
|
|
this.hsPattern = hsPattern;
|
|
}
|
|
|
|
equals(b: SymmetricState): boolean {
|
|
return (
|
|
this.cs.equals(b.cs) &&
|
|
uint8ArrayEquals(this.ck, b.ck) &&
|
|
uint8ArrayEquals(this.h, b.h) &&
|
|
this.hsPattern.equals(b.hsPattern)
|
|
);
|
|
}
|
|
|
|
clone(): SymmetricState {
|
|
const ss = new SymmetricState(this.hsPattern);
|
|
ss.cs = this.cs.clone();
|
|
ss.ck = new Uint8Array(this.ck);
|
|
ss.h = new Uint8Array(this.h);
|
|
return ss;
|
|
}
|
|
|
|
// MixKey as per Noise specification http://www.noiseprotocol.org/noise.html#the-symmetricstate-object
|
|
// Updates a Symmetric state chaining key and symmetric state
|
|
mixKey(inputKeyMaterial: Uint8Array): void {
|
|
// We derive two keys using HKDF
|
|
const [ck, tempK] = getHKDF(this.ck, inputKeyMaterial);
|
|
// We update ck and the Cipher state's key k using the output of HDKF
|
|
this.cs = new CipherState(tempK);
|
|
this.ck = ck;
|
|
log("mixKey", this.ck, this.cs.k);
|
|
}
|
|
|
|
// MixHash as per Noise specification http://www.noiseprotocol.org/noise.html#the-symmetricstate-object
|
|
// Hashes data into a Symmetric State's handshake hash value h
|
|
mixHash(data: Uint8Array): void {
|
|
// We hash the previous handshake hash and input data and store the result in the Symmetric State's handshake hash value
|
|
this.h = hashSHA256(uint8ArrayConcat([this.h, data]));
|
|
log("mixHash", this.h);
|
|
}
|
|
|
|
// mixKeyAndHash as per Noise specification http://www.noiseprotocol.org/noise.html#the-symmetricstate-object
|
|
// Combines MixKey and MixHash
|
|
mixKeyAndHash(inputKeyMaterial: Uint8Array): void {
|
|
// Derives 3 keys using HKDF, the chaining key and the input key material
|
|
const [tmpKey0, tmpKey1, tmpKey2] = getHKDF(this.ck, inputKeyMaterial);
|
|
// Sets the chaining key
|
|
this.ck = tmpKey0;
|
|
// Updates the handshake hash value
|
|
this.mixHash(tmpKey1);
|
|
// Updates the Cipher state's key
|
|
// Note for later support of 512 bits hash functions: "If HASHLEN is 64, then truncates tempKeys[2] to 32 bytes."
|
|
this.cs = new CipherState(tmpKey2);
|
|
}
|
|
|
|
// EncryptAndHash as per Noise specification http://www.noiseprotocol.org/noise.html#the-symmetricstate-object
|
|
// Combines encryptWithAd and mixHash
|
|
// Note that by setting extraAd, it is possible to pass extra additional data that will be concatenated to the ad specified by Noise (can be used to authenticate messageNametag)
|
|
encryptAndHash(plaintext: Uint8Array, extraAd: Uint8Array = new Uint8Array()): Uint8Array {
|
|
// The additional data
|
|
const ad = uint8ArrayConcat([this.h, extraAd]);
|
|
// Note that if an encryption key is not set yet in the Cipher state, ciphertext will be equal to plaintext
|
|
const ciphertext = this.cs.encryptWithAd(ad, plaintext);
|
|
// We call mixHash over the result
|
|
this.mixHash(ciphertext);
|
|
|
|
return ciphertext;
|
|
}
|
|
|
|
// DecryptAndHash as per Noise specification http://www.noiseprotocol.org/noise.html#the-symmetricstate-object
|
|
// Combines decryptWithAd and mixHash
|
|
decryptAndHash(ciphertext: Uint8Array, extraAd: Uint8Array = new Uint8Array()): Uint8Array {
|
|
// The additional data
|
|
const ad = uint8ArrayConcat([this.h, extraAd]);
|
|
// Note that if an encryption key is not set yet in the Cipher state, plaintext will be equal to ciphertext
|
|
const plaintext = this.cs.decryptWithAd(ad, ciphertext);
|
|
// According to specification, the ciphertext enters mixHash (and not the plaintext)
|
|
this.mixHash(ciphertext);
|
|
|
|
return plaintext;
|
|
}
|
|
|
|
// Split as per Noise specification http://www.noiseprotocol.org/noise.html#the-symmetricstate-object
|
|
// Once a handshake is complete, returns two Cipher States to encrypt/decrypt outbound/inbound messages
|
|
split(): { cs1: CipherState; cs2: CipherState } {
|
|
// Derives 2 keys using HKDF and the chaining key
|
|
const [tmpKey1, tmpKey2] = getHKDF(this.ck, new Uint8Array(0));
|
|
// Returns a tuple of two Cipher States initialized with the derived keys
|
|
return {
|
|
cs1: new CipherState(tmpKey1),
|
|
cs2: new CipherState(tmpKey2),
|
|
};
|
|
}
|
|
|
|
// Gets the chaining key field of a Symmetric State
|
|
getChainingKey(): bytes32 {
|
|
return this.ck;
|
|
}
|
|
|
|
// Gets the handshake hash field of a Symmetric State
|
|
getHandshakeHash(): bytes32 {
|
|
return this.h;
|
|
}
|
|
|
|
// Gets the Cipher State field of a Symmetric State
|
|
getCipherState(): CipherState {
|
|
return this.cs;
|
|
}
|
|
}
|