smp web: implement protocol encodings and encryption end-to-end (#1778)

* smp web: protocol encodings and x3dh

* fix

* strnup761 compiled to wasm

* AES-256-GCM, comatibility tests

* core of double ratchet

* PQ double ratchet

* test typescript ratchets

* agent encoding/encryption stack with test

---------

Co-authored-by: Evgeny @ SimpleX Chat <259188159+evgeny-simplex@users.noreply.github.com>
This commit is contained in:
Evgeny
2026-05-16 18:18:45 +01:00
committed by GitHub
parent a0af7377ab
commit 34e3d30c78
15 changed files with 3077 additions and 27 deletions
+1
View File
@@ -1,3 +1,4 @@
node_modules/
dist/
dist-test/
package-lock.json
+14
View File
@@ -0,0 +1,14 @@
addToLibrary({
js_random_bytes: function(buf, len) {
var bytes = new Uint8Array(len);
if (typeof crypto !== 'undefined' && crypto.getRandomValues) {
crypto.getRandomValues(bytes);
} else {
// Node.js fallback
var nodeCrypto = require('crypto');
var nodeBytes = nodeCrypto.randomBytes(len);
bytes.set(nodeBytes);
}
HEAPU8.set(bytes, buf);
}
});
+315
View File
@@ -0,0 +1,315 @@
/*
20080913
D. J. Bernstein
Public domain.
SHA-512 implementation from SUPERCOP/NaCl.
Source: https://bench.cr.yp.to/supercop.html
crypto_hashblocks/sha512/ref/blocks.c
crypto_hash/sha512/ref/hash.c
Combined into a single file for WASM compilation alongside sntrup761.
*/
#include "sha512.h"
typedef unsigned long long uint64;
/* -- crypto_hashblocks_sha512 (blocks.c) -- */
static uint64 load_bigendian(const unsigned char *x)
{
return
(uint64) (x[7]) \
| (((uint64) (x[6])) << 8) \
| (((uint64) (x[5])) << 16) \
| (((uint64) (x[4])) << 24) \
| (((uint64) (x[3])) << 32) \
| (((uint64) (x[2])) << 40) \
| (((uint64) (x[1])) << 48) \
| (((uint64) (x[0])) << 56)
;
}
static void store_bigendian(unsigned char *x,uint64 u)
{
x[7] = u; u >>= 8;
x[6] = u; u >>= 8;
x[5] = u; u >>= 8;
x[4] = u; u >>= 8;
x[3] = u; u >>= 8;
x[2] = u; u >>= 8;
x[1] = u; u >>= 8;
x[0] = u;
}
#define SHR(x,c) ((x) >> (c))
#define ROTR(x,c) (((x) >> (c)) | ((x) << (64 - (c))))
#define Ch(x,y,z) ((x & y) ^ (~x & z))
#define Maj(x,y,z) ((x & y) ^ (x & z) ^ (y & z))
#define Sigma0(x) (ROTR(x,28) ^ ROTR(x,34) ^ ROTR(x,39))
#define Sigma1(x) (ROTR(x,14) ^ ROTR(x,18) ^ ROTR(x,41))
#define sigma0(x) (ROTR(x, 1) ^ ROTR(x, 8) ^ SHR(x,7))
#define sigma1(x) (ROTR(x,19) ^ ROTR(x,61) ^ SHR(x,6))
#define M(w0,w14,w9,w1) w0 = sigma1(w14) + w9 + sigma0(w1) + w0;
#define EXPAND \
M(w0 ,w14,w9 ,w1 ) \
M(w1 ,w15,w10,w2 ) \
M(w2 ,w0 ,w11,w3 ) \
M(w3 ,w1 ,w12,w4 ) \
M(w4 ,w2 ,w13,w5 ) \
M(w5 ,w3 ,w14,w6 ) \
M(w6 ,w4 ,w15,w7 ) \
M(w7 ,w5 ,w0 ,w8 ) \
M(w8 ,w6 ,w1 ,w9 ) \
M(w9 ,w7 ,w2 ,w10) \
M(w10,w8 ,w3 ,w11) \
M(w11,w9 ,w4 ,w12) \
M(w12,w10,w5 ,w13) \
M(w13,w11,w6 ,w14) \
M(w14,w12,w7 ,w15) \
M(w15,w13,w8 ,w0 )
#define F(w,k) \
T1 = h + Sigma1(e) + Ch(e,f,g) + k + w; \
T2 = Sigma0(a) + Maj(a,b,c); \
h = g; \
g = f; \
f = e; \
e = d + T1; \
d = c; \
c = b; \
b = a; \
a = T1 + T2;
static int crypto_hashblocks_sha512(unsigned char *statebytes,const unsigned char *in,unsigned long long inlen)
{
uint64 state[8];
uint64 a;
uint64 b;
uint64 c;
uint64 d;
uint64 e;
uint64 f;
uint64 g;
uint64 h;
uint64 T1;
uint64 T2;
a = load_bigendian(statebytes + 0); state[0] = a;
b = load_bigendian(statebytes + 8); state[1] = b;
c = load_bigendian(statebytes + 16); state[2] = c;
d = load_bigendian(statebytes + 24); state[3] = d;
e = load_bigendian(statebytes + 32); state[4] = e;
f = load_bigendian(statebytes + 40); state[5] = f;
g = load_bigendian(statebytes + 48); state[6] = g;
h = load_bigendian(statebytes + 56); state[7] = h;
while (inlen >= 128) {
uint64 w0 = load_bigendian(in + 0);
uint64 w1 = load_bigendian(in + 8);
uint64 w2 = load_bigendian(in + 16);
uint64 w3 = load_bigendian(in + 24);
uint64 w4 = load_bigendian(in + 32);
uint64 w5 = load_bigendian(in + 40);
uint64 w6 = load_bigendian(in + 48);
uint64 w7 = load_bigendian(in + 56);
uint64 w8 = load_bigendian(in + 64);
uint64 w9 = load_bigendian(in + 72);
uint64 w10 = load_bigendian(in + 80);
uint64 w11 = load_bigendian(in + 88);
uint64 w12 = load_bigendian(in + 96);
uint64 w13 = load_bigendian(in + 104);
uint64 w14 = load_bigendian(in + 112);
uint64 w15 = load_bigendian(in + 120);
F(w0 ,0x428a2f98d728ae22ULL)
F(w1 ,0x7137449123ef65cdULL)
F(w2 ,0xb5c0fbcfec4d3b2fULL)
F(w3 ,0xe9b5dba58189dbbcULL)
F(w4 ,0x3956c25bf348b538ULL)
F(w5 ,0x59f111f1b605d019ULL)
F(w6 ,0x923f82a4af194f9bULL)
F(w7 ,0xab1c5ed5da6d8118ULL)
F(w8 ,0xd807aa98a3030242ULL)
F(w9 ,0x12835b0145706fbeULL)
F(w10,0x243185be4ee4b28cULL)
F(w11,0x550c7dc3d5ffb4e2ULL)
F(w12,0x72be5d74f27b896fULL)
F(w13,0x80deb1fe3b1696b1ULL)
F(w14,0x9bdc06a725c71235ULL)
F(w15,0xc19bf174cf692694ULL)
EXPAND
F(w0 ,0xe49b69c19ef14ad2ULL)
F(w1 ,0xefbe4786384f25e3ULL)
F(w2 ,0x0fc19dc68b8cd5b5ULL)
F(w3 ,0x240ca1cc77ac9c65ULL)
F(w4 ,0x2de92c6f592b0275ULL)
F(w5 ,0x4a7484aa6ea6e483ULL)
F(w6 ,0x5cb0a9dcbd41fbd4ULL)
F(w7 ,0x76f988da831153b5ULL)
F(w8 ,0x983e5152ee66dfabULL)
F(w9 ,0xa831c66d2db43210ULL)
F(w10,0xb00327c898fb213fULL)
F(w11,0xbf597fc7beef0ee4ULL)
F(w12,0xc6e00bf33da88fc2ULL)
F(w13,0xd5a79147930aa725ULL)
F(w14,0x06ca6351e003826fULL)
F(w15,0x142929670a0e6e70ULL)
EXPAND
F(w0 ,0x27b70a8546d22ffcULL)
F(w1 ,0x2e1b21385c26c926ULL)
F(w2 ,0x4d2c6dfc5ac42aedULL)
F(w3 ,0x53380d139d95b3dfULL)
F(w4 ,0x650a73548baf63deULL)
F(w5 ,0x766a0abb3c77b2a8ULL)
F(w6 ,0x81c2c92e47edaee6ULL)
F(w7 ,0x92722c851482353bULL)
F(w8 ,0xa2bfe8a14cf10364ULL)
F(w9 ,0xa81a664bbc423001ULL)
F(w10,0xc24b8b70d0f89791ULL)
F(w11,0xc76c51a30654be30ULL)
F(w12,0xd192e819d6ef5218ULL)
F(w13,0xd69906245565a910ULL)
F(w14,0xf40e35855771202aULL)
F(w15,0x106aa07032bbd1b8ULL)
EXPAND
F(w0 ,0x19a4c116b8d2d0c8ULL)
F(w1 ,0x1e376c085141ab53ULL)
F(w2 ,0x2748774cdf8eeb99ULL)
F(w3 ,0x34b0bcb5e19b48a8ULL)
F(w4 ,0x391c0cb3c5c95a63ULL)
F(w5 ,0x4ed8aa4ae3418acbULL)
F(w6 ,0x5b9cca4f7763e373ULL)
F(w7 ,0x682e6ff3d6b2b8a3ULL)
F(w8 ,0x748f82ee5defb2fcULL)
F(w9 ,0x78a5636f43172f60ULL)
F(w10,0x84c87814a1f0ab72ULL)
F(w11,0x8cc702081a6439ecULL)
F(w12,0x90befffa23631e28ULL)
F(w13,0xa4506cebde82bde9ULL)
F(w14,0xbef9a3f7b2c67915ULL)
F(w15,0xc67178f2e372532bULL)
EXPAND
F(w0 ,0xca273eceea26619cULL)
F(w1 ,0xd186b8c721c0c207ULL)
F(w2 ,0xeada7dd6cde0eb1eULL)
F(w3 ,0xf57d4f7fee6ed178ULL)
F(w4 ,0x06f067aa72176fbaULL)
F(w5 ,0x0a637dc5a2c898a6ULL)
F(w6 ,0x113f9804bef90daeULL)
F(w7 ,0x1b710b35131c471bULL)
F(w8 ,0x28db77f523047d84ULL)
F(w9 ,0x32caab7b40c72493ULL)
F(w10,0x3c9ebe0a15c9bebcULL)
F(w11,0x431d67c49c100d4cULL)
F(w12,0x4cc5d4becb3e42b6ULL)
F(w13,0x597f299cfc657e2aULL)
F(w14,0x5fcb6fab3ad6faecULL)
F(w15,0x6c44198c4a475817ULL)
a += state[0];
b += state[1];
c += state[2];
d += state[3];
e += state[4];
f += state[5];
g += state[6];
h += state[7];
state[0] = a;
state[1] = b;
state[2] = c;
state[3] = d;
state[4] = e;
state[5] = f;
state[6] = g;
state[7] = h;
in += 128;
inlen -= 128;
}
store_bigendian(statebytes + 0,state[0]);
store_bigendian(statebytes + 8,state[1]);
store_bigendian(statebytes + 16,state[2]);
store_bigendian(statebytes + 24,state[3]);
store_bigendian(statebytes + 32,state[4]);
store_bigendian(statebytes + 40,state[5]);
store_bigendian(statebytes + 48,state[6]);
store_bigendian(statebytes + 56,state[7]);
return inlen;
}
/* -- crypto_hash_sha512 (hash.c) -- */
static const unsigned char iv[64] = {
0x6a,0x09,0xe6,0x67,0xf3,0xbc,0xc9,0x08,
0xbb,0x67,0xae,0x85,0x84,0xca,0xa7,0x3b,
0x3c,0x6e,0xf3,0x72,0xfe,0x94,0xf8,0x2b,
0xa5,0x4f,0xf5,0x3a,0x5f,0x1d,0x36,0xf1,
0x51,0x0e,0x52,0x7f,0xad,0xe6,0x82,0xd1,
0x9b,0x05,0x68,0x8c,0x2b,0x3e,0x6c,0x1f,
0x1f,0x83,0xd9,0xab,0xfb,0x41,0xbd,0x6b,
0x5b,0xe0,0xcd,0x19,0x13,0x7e,0x21,0x79
};
void crypto_hash_sha512(unsigned char *out,
const unsigned char *in,
unsigned long long inlen)
{
unsigned char h[64];
unsigned char padded[256];
int i;
unsigned long long bytes = inlen;
for (i = 0;i < 64;++i) h[i] = iv[i];
crypto_hashblocks_sha512(h,in,inlen);
in += inlen;
inlen &= 127;
in -= inlen;
for (i = 0;i < (int)inlen;++i) padded[i] = in[i];
padded[inlen] = 0x80;
if (inlen < 112) {
for (i = inlen + 1;i < 119;++i) padded[i] = 0;
padded[119] = bytes >> 61;
padded[120] = bytes >> 53;
padded[121] = bytes >> 45;
padded[122] = bytes >> 37;
padded[123] = bytes >> 29;
padded[124] = bytes >> 21;
padded[125] = bytes >> 13;
padded[126] = bytes >> 5;
padded[127] = bytes << 3;
crypto_hashblocks_sha512(h,padded,128);
} else {
for (i = inlen + 1;i < 247;++i) padded[i] = 0;
padded[247] = bytes >> 61;
padded[248] = bytes >> 53;
padded[249] = bytes >> 45;
padded[250] = bytes >> 37;
padded[251] = bytes >> 29;
padded[252] = bytes >> 21;
padded[253] = bytes >> 13;
padded[254] = bytes >> 5;
padded[255] = bytes << 3;
crypto_hashblocks_sha512(h,padded,256);
}
for (i = 0;i < 64;++i) out[i] = h[i];
}
+11
View File
@@ -0,0 +1,11 @@
interface Sntrup761Module {
_sntrup761_wasm_keypair(pk: number, sk: number): void
_sntrup761_wasm_enc(c: number, k: number, pk: number): void
_sntrup761_wasm_dec(k: number, c: number, sk: number): void
_malloc(size: number): number
_free(ptr: number): void
HEAPU8: Uint8Array
}
declare function createSntrup761(): Promise<Sntrup761Module>
export default createSntrup761
+34
View File
@@ -0,0 +1,34 @@
/*
* WASM wrapper for sntrup761.
* Provides JS-callable functions with RNG from JS imports.
*
* Build: emcc sntrup761_wasm.c sntrup761.c sha512.c -O2 -o sntrup761.js \
* -s EXPORTED_FUNCTIONS='["_sntrup761_wasm_keypair","_sntrup761_wasm_enc","_sntrup761_wasm_dec","_malloc","_free"]' \
* -s EXPORTED_RUNTIME_METHODS='["ccall","cwrap"]'
*/
#include "sntrup761.h"
#include <stdlib.h>
/* Import RNG from JS environment */
extern void js_random_bytes(unsigned char *buf, int len);
/* RNG callback adapter for sntrup761 */
static void wasm_random(void *ctx, size_t length, uint8_t *dst) {
(void)ctx;
js_random_bytes(dst, (int)length);
}
/* JS-callable wrappers */
void sntrup761_wasm_keypair(unsigned char *pk, unsigned char *sk) {
sntrup761_keypair(pk, sk, NULL, wasm_random);
}
void sntrup761_wasm_enc(unsigned char *c, unsigned char *k, const unsigned char *pk) {
sntrup761_enc(c, k, pk, NULL, wasm_random);
}
void sntrup761_wasm_dec(unsigned char *k, const unsigned char *c, const unsigned char *sk) {
sntrup761_dec(k, c, sk);
}
+8 -2
View File
@@ -14,11 +14,17 @@
"dist"
],
"scripts": {
"build": "tsc"
"build:wasm": "mkdir -p dist/wasm && npx emcc cbits/sntrup761_wasm.c ../cbits/sntrup761.c cbits/sha512.c -I../cbits -O2 -o dist/wasm/sntrup761.mjs -s EXPORTED_FUNCTIONS='[\"_sntrup761_wasm_keypair\",\"_sntrup761_wasm_enc\",\"_sntrup761_wasm_dec\",\"_malloc\",\"_free\"]' -s EXPORTED_RUNTIME_METHODS='[\"ccall\",\"cwrap\",\"HEAPU8\"]' -s MODULARIZE=1 -s EXPORT_NAME='createSntrup761' -s ALLOW_MEMORY_GROWTH=1 -s ENVIRONMENT='web,node' --js-library cbits/js_random.js && cp cbits/sntrup761.d.mts dist/wasm/",
"build:ts": "tsc",
"build:test": "tsc -p tsconfig.test.json",
"build": "npm run build:wasm && npm run build:ts && npm run build:test"
},
"dependencies": {
"@noble/ciphers": "^2.2.0",
"@noble/curves": "^2.2.0",
"@noble/hashes": "^1.5.0",
"@simplex-chat/xftp-web": "file:../xftp-web"
"@simplex-chat/xftp-web": "file:../xftp-web",
"emsdk": "^0.4.0"
},
"devDependencies": {
"@types/node": "^25.5.0",
+207
View File
@@ -0,0 +1,207 @@
// Agent message encoding/decoding.
// Mirrors: Simplex.Messaging.Agent.Protocol (AgentMsgEnvelope, AgentMessage, APrivHeader, AMessage)
import {
Decoder, concatBytes,
encodeBytes, decodeBytes,
encodeLarge, decodeLarge,
encodeInt64, decodeInt64,
encodeWord16, decodeWord16,
encodeMaybe, decodeMaybe,
encodeNonEmpty, decodeNonEmpty,
} from "@simplex-chat/xftp-web/dist/protocol/encoding.js"
// -- Constants (Agent/Protocol.hs:318-319)
export const currentSMPAgentVersion = 7
// -- AMessage (Agent/Protocol.hs:1001-1020)
export type AMessage =
| {type: "HELLO"}
| {type: "A_MSG", body: Uint8Array}
| {type: "A_RCVD", receipts: AMessageReceipt[]} // NonEmpty
| {type: "EREADY", lastDecryptedMsgId: bigint}
// Agent/Protocol.hs:1040-1045
export interface AMessageReceipt {
agentMsgId: bigint // Int64
msgHash: Uint8Array // ByteString (32-byte SHA-256)
rcptInfo: Uint8Array // MsgReceiptInfo (ByteString, Large-encoded)
}
// Agent/Protocol.hs:1078-1100
export function encodeAMessage(msg: AMessage): Uint8Array {
switch (msg.type) {
case "HELLO": return new Uint8Array([0x48]) // "H"
case "A_MSG": return concatBytes(new Uint8Array([0x4D]), msg.body) // "M" + Tail
case "A_RCVD": return concatBytes(new Uint8Array([0x56]), encodeNonEmpty(encodeAMessageReceipt, msg.receipts)) // "V" + NonEmpty
case "EREADY": return concatBytes(new Uint8Array([0x45]), encodeInt64(msg.lastDecryptedMsgId)) // "E" + Int64
}
}
export function decodeAMessage(d: Decoder): AMessage {
const tag = d.anyByte()
switch (tag) {
case 0x48: return {type: "HELLO"} // 'H'
case 0x4D: return {type: "A_MSG", body: d.takeAll()} // 'M' + Tail
case 0x56: return {type: "A_RCVD", receipts: decodeNonEmpty(decodeAMessageReceipt, d)} // 'V'
case 0x45: return {type: "EREADY", lastDecryptedMsgId: decodeInt64(d)} // 'E'
// Queue management tags (not needed for chat messages, but recognized for decoding)
case 0x51: { // 'Q'
const sub = d.anyByte()
switch (sub) {
case 0x43: // 'C' = A_QCONT
case 0x41: // 'A' = QADD
case 0x4B: // 'K' = QKEY
case 0x55: // 'U' = QUSE
case 0x54: // 'T' = QTEST
throw new Error("decodeAMessage: queue management message (Q" + String.fromCharCode(sub) + ") not implemented")
default:
throw new Error("decodeAMessage: unknown Q-subtag " + sub)
}
}
default:
throw new Error("decodeAMessage: unknown tag " + tag)
}
}
// Agent/Protocol.hs:1106-1111
function encodeAMessageReceipt(r: AMessageReceipt): Uint8Array {
return concatBytes(encodeInt64(r.agentMsgId), encodeBytes(r.msgHash), encodeLarge(r.rcptInfo))
}
function decodeAMessageReceipt(d: Decoder): AMessageReceipt {
return {agentMsgId: decodeInt64(d), msgHash: decodeBytes(d), rcptInfo: decodeLarge(d)}
}
// -- APrivHeader (Agent/Protocol.hs:946-957)
export interface APrivHeader {
sndMsgId: bigint // AgentMsgId = Int64
prevMsgHash: Uint8Array // MsgHash = ByteString
}
export function encodeAPrivHeader(h: APrivHeader): Uint8Array {
return concatBytes(encodeInt64(h.sndMsgId), encodeBytes(h.prevMsgHash))
}
export function decodeAPrivHeader(d: Decoder): APrivHeader {
return {sndMsgId: decodeInt64(d), prevMsgHash: decodeBytes(d)}
}
// -- AgentMessage (Agent/Protocol.hs:866-888)
export type AgentMessage =
| {type: "connInfo", cInfo: Uint8Array}
| {type: "connInfoReply", smpQueues: Uint8Array[], cInfo: Uint8Array} // NonEmpty raw-encoded SMPQueueInfo
| {type: "ratchetInfo", info: Uint8Array}
| {type: "message", header: APrivHeader, msg: AMessage}
export function encodeAgentMessage(msg: AgentMessage): Uint8Array {
switch (msg.type) {
case "connInfo":
return concatBytes(new Uint8Array([0x49]), msg.cInfo) // 'I' + Tail
case "connInfoReply":
// 'D' + NonEmpty SMPQueueInfo + Tail cInfo
// SMPQueueInfo encoding is complex; for now encode the raw bytes
return concatBytes(
new Uint8Array([0x44]),
encodeNonEmpty(b => b, msg.smpQueues),
msg.cInfo,
)
case "ratchetInfo":
return concatBytes(new Uint8Array([0x52]), msg.info) // 'R' + Tail
case "message":
return concatBytes(new Uint8Array([0x4D]), encodeAPrivHeader(msg.header), encodeAMessage(msg.msg)) // 'M' + header + msg
}
}
export function decodeAgentMessage(d: Decoder): AgentMessage {
const tag = d.anyByte()
switch (tag) {
case 0x49: return {type: "connInfo", cInfo: d.takeAll()} // 'I' + Tail
case 0x44: { // 'D'
// NonEmpty SMPQueueInfo is complex to decode; skip for now, just capture raw
throw new Error("decodeAgentMessage: connInfoReply ('D') not implemented")
}
case 0x52: return {type: "ratchetInfo", info: d.takeAll()} // 'R' + Tail
case 0x4D: return {type: "message", header: decodeAPrivHeader(d), msg: decodeAMessage(d)} // 'M'
default:
throw new Error("decodeAgentMessage: unknown tag " + tag)
}
}
// -- AgentMsgEnvelope (Agent/Protocol.hs:812-861)
export type AgentMsgEnvelope =
| {type: "confirmation", agentVersion: number, e2eEncryption: Uint8Array | null, encConnInfo: Uint8Array}
| {type: "envelope", agentVersion: number, encAgentMessage: Uint8Array}
| {type: "invitation", agentVersion: number, connReqBytes: Uint8Array, connInfo: Uint8Array}
| {type: "ratchetKey", agentVersion: number, e2eEncryption: Uint8Array, info: Uint8Array}
// Agent/Protocol.hs:835-843
export function encodeAgentMsgEnvelope(env: AgentMsgEnvelope): Uint8Array {
switch (env.type) {
case "confirmation":
// (agentVersion, 'C', Maybe SndE2ERatchetParams, Tail encConnInfo)
return concatBytes(
encodeWord16(env.agentVersion),
new Uint8Array([0x43]), // 'C'
encodeMaybe(b => b, env.e2eEncryption), // e2eEncryption is already smpEncoded bytes or null
env.encConnInfo, // Tail
)
case "envelope":
// (agentVersion, 'M', Tail encAgentMessage)
return concatBytes(
encodeWord16(env.agentVersion),
new Uint8Array([0x4D]), // 'M'
env.encAgentMessage, // Tail
)
case "invitation":
// (agentVersion, 'I', Large connReqBytes, Tail connInfo)
return concatBytes(
encodeWord16(env.agentVersion),
new Uint8Array([0x49]), // 'I'
encodeLarge(env.connReqBytes),
env.connInfo, // Tail
)
case "ratchetKey":
// (agentVersion, 'R', e2eEncryption, Tail info)
return concatBytes(
encodeWord16(env.agentVersion),
new Uint8Array([0x52]), // 'R'
env.e2eEncryption, // already smpEncoded
env.info, // Tail
)
}
}
// Agent/Protocol.hs:844-861
export function decodeAgentMsgEnvelope(d: Decoder): AgentMsgEnvelope {
const agentVersion = decodeWord16(d)
const tag = d.anyByte()
switch (tag) {
case 0x43: // 'C' Confirmation
// e2eEncryption_ is Maybe (SndE2ERatchetParams 'X448), encConnInfo is Tail
// Full parsing of E2ERatchetParams needed to split the boundary — not implemented in spike
throw new Error("decodeAgentMsgEnvelope: confirmation ('C') not fully implemented")
case 0x4D: // 'M' Message envelope
return {type: "envelope", agentVersion, encAgentMessage: d.takeAll()} // Tail
case 0x49: { // 'I' Invitation
const connReqBytes = decodeLarge(d)
const connInfo = d.takeAll() // Tail
return {type: "invitation", agentVersion, connReqBytes, connInfo}
}
case 0x52: { // 'R' RatchetKey
// e2eEncryption is an E2ERatchetParams — variable-length, not Tail
// For now, capture remaining minus nothing (since info is Tail and comes last)
// This is tricky: e2eEncryption is smpEncoded E2ERatchetParams, info is Tail
// We can't easily split without knowing the E2ERatchetParams length
// For the spike, just capture all remaining as raw
throw new Error("decodeAgentMsgEnvelope: ratchetKey ('R') not fully implemented")
}
default:
throw new Error("decodeAgentMsgEnvelope: unknown tag " + tag)
}
}
+39
View File
@@ -3,7 +3,10 @@
import {hkdf as nobleHkdf} from "@noble/hashes/hkdf"
import {sha512} from "@noble/hashes/sha512"
import {gcm} from "@noble/ciphers/aes.js"
import {cbEncrypt, cbDecrypt} from "@simplex-chat/xftp-web/dist/crypto/secretbox.js"
import {concatBytes} from "@simplex-chat/xftp-web/dist/protocol/encoding.js"
import {pad, unPad} from "@simplex-chat/xftp-web/dist/crypto/padding.js"
// C.hkdf (Crypto.hs:1461-1464)
// HKDF-SHA512 extract + expand
@@ -48,3 +51,39 @@ export function sbDecryptBlock(chainKey: Uint8Array, block: Uint8Array): {decryp
const {keyNonce: {sbKey, nonce}, nextChainKey} = sbcHkdf(chainKey)
return {decrypted: cbDecrypt(sbKey, nonce, block), nextChainKey}
}
// -- AES-256-GCM authenticated encryption (Crypto.hs:1035-1061)
// Uses 16-byte IVs (GCM with GHASH path per NIST SP 800-38D for IVs != 96 bits)
export const AUTH_TAG_SIZE = 16
// encryptAEAD (Crypto.hs:1035-1039)
export function encryptAEAD(
key: Uint8Array, // 32 bytes
iv: Uint8Array, // 16 bytes
paddedLen: number,
ad: Uint8Array,
plaintext: Uint8Array,
): {authTag: Uint8Array; ciphertext: Uint8Array} {
const padded = pad(plaintext, paddedLen)
const cipher = gcm(key, iv, ad)
const encrypted = cipher.encrypt(padded)
return {
ciphertext: encrypted.subarray(0, encrypted.length - AUTH_TAG_SIZE),
authTag: encrypted.subarray(encrypted.length - AUTH_TAG_SIZE),
}
}
// decryptAEAD (Crypto.hs:1058-1061)
export function decryptAEAD(
key: Uint8Array,
iv: Uint8Array,
ad: Uint8Array,
ciphertext: Uint8Array,
authTag: Uint8Array,
): Uint8Array {
const cipher = gcm(key, iv, ad)
const encrypted = concatBytes(ciphertext, authTag)
const padded = cipher.decrypt(encrypted)
return unPad(padded)
}
+749
View File
@@ -0,0 +1,749 @@
// Double ratchet with X3DH key agreement and PQ KEM.
// Faithful transpilation of Simplex.Messaging.Crypto.Ratchet
//
// Every type, field, and function mirrors the Haskell source.
// Line references are to src/Simplex/Messaging/Crypto/Ratchet.hs
import {x448} from "@noble/curves/ed448.js"
import {hkdf, encryptAEAD, decryptAEAD, AUTH_TAG_SIZE} from "../crypto.js"
import {sntrup761Keypair, sntrup761Enc, sntrup761Dec} from "./sntrup761.js"
import type {KEMKeyPair} from "./sntrup761.js"
import {
Decoder, concatBytes,
encodeBytes, decodeBytes, decodeWord16, decodeWord32,
encodeLarge, decodeLarge,
encodeMaybe,
} from "@simplex-chat/xftp-web/dist/protocol/encoding.js"
// -- Version constants (lines 134-155)
export const pqRatchetE2EEncryptVersion = 3
export const currentE2EEncryptVersion = 3
// -- X448 key operations
export interface X448KeyPair {
publicKey: Uint8Array // 56 bytes
privateKey: Uint8Array // 56 bytes
}
export function generateX448KeyPair(): X448KeyPair {
const privateKey = x448.utils.randomSecretKey()
const publicKey = x448.getPublicKey(privateKey)
return {publicKey, privateKey}
}
export function x448DH(publicKey: Uint8Array, privateKey: Uint8Array): Uint8Array {
return x448.getSharedSecret(privateKey, publicKey)
}
// DER encoding for X448 public keys (RFC 8410, SubjectPublicKeyInfo)
// SEQUENCE { SEQUENCE { OID 1.3.101.111 } BIT STRING { 0x00 <56 bytes> } }
const X448_PUBKEY_DER_PREFIX = new Uint8Array([
0x30, 0x42, 0x30, 0x05, 0x06, 0x03, 0x2b, 0x65, 0x6f, 0x03, 0x39, 0x00,
])
export function encodePubKeyX448(rawPubKey: Uint8Array): Uint8Array {
return concatBytes(X448_PUBKEY_DER_PREFIX, rawPubKey)
}
export function decodePubKeyX448(der: Uint8Array): Uint8Array {
if (der.length !== 68) throw new Error("decodePubKeyX448: invalid length " + der.length)
for (let i = 0; i < X448_PUBKEY_DER_PREFIX.length; i++) {
if (der[i] !== X448_PUBKEY_DER_PREFIX[i]) throw new Error("decodePubKeyX448: invalid DER prefix")
}
return der.subarray(12)
}
// -- KEM types (lines 567-577)
// KEMKeyPair imported from ./sntrup761.js
export interface RatchetKEMAccepted {
rcPQRr: Uint8Array // KEMPublicKey - received key (1158 bytes)
rcPQRss: Uint8Array // KEMSharedKey - computed shared secret (32 bytes)
rcPQRct: Uint8Array // KEMCiphertext - sent encaps (1039 bytes)
}
export interface RatchetKEM {
rcPQRs: KEMKeyPair
rcKEMs: RatchetKEMAccepted | null
}
// -- RatchetInitParams (lines 457-464)
export interface RatchetInitParams {
assocData: Uint8Array // Str (raw bytes)
ratchetKey: Uint8Array // RatchetKey (32 bytes)
sndHK: Uint8Array // HeaderKey (32 bytes)
rcvNextHK: Uint8Array // HeaderKey (32 bytes)
kemAccepted: RatchetKEMAccepted | null // Maybe RatchetKEMAccepted
}
// -- hkdf3 (lines 1174-1179)
function hkdf3(salt: Uint8Array, ikm: Uint8Array, info: string): [Uint8Array, Uint8Array, Uint8Array] {
const out = hkdf(salt, ikm, info, 96)
return [out.slice(0, 32), out.slice(32, 64), out.slice(64, 96)]
}
// -- pqX3dh (lines 499-508)
const X3DH_SALT = new Uint8Array(64)
function pqX3dh(
sk1: Uint8Array, rk1: Uint8Array,
dh1: Uint8Array, dh2: Uint8Array, dh3: Uint8Array,
kemAccepted: RatchetKEMAccepted | null,
): RatchetInitParams {
const assocData = concatBytes(sk1, rk1)
const pq = kemAccepted ? kemAccepted.rcPQRss : new Uint8Array(0)
const dhs = concatBytes(dh1, dh2, dh3, pq)
const [hk, nhk, sk] = hkdf3(X3DH_SALT, dhs, "SimpleXX3DH")
return {assocData, ratchetKey: sk, sndHK: hk, rcvNextHK: nhk, kemAccepted}
}
// -- pqX3dhSnd (lines 467-480)
// Used by joiner (Alice in PQDR spec, Bob in DR spec) to init SENDING ratchet.
export function pqX3dhSnd(
spk1: Uint8Array, spk2: Uint8Array, // our private keys
rk1: Uint8Array, rk2: Uint8Array, // their public keys (raw)
kemAccepted: RatchetKEMAccepted | null = null,
): RatchetInitParams {
const sk1Pub = x448.getPublicKey(spk1)
const dh1 = x448DH(rk1, spk2)
const dh2 = x448DH(rk2, spk1)
const dh3 = x448DH(rk2, spk2)
return pqX3dh(sk1Pub, rk1, dh1, dh2, dh3, kemAccepted)
}
// -- pqX3dhRcv (lines 483-497)
// Used by initiator (Bob in PQDR spec, Alice in DR spec) to init RECEIVING ratchet.
export function pqX3dhRcv(
rpk1: Uint8Array, rpk2: Uint8Array, // our private keys
sk1: Uint8Array, sk2: Uint8Array, // their public keys (raw)
kemAccepted: RatchetKEMAccepted | null = null,
): RatchetInitParams {
const rk1Pub = x448.getPublicKey(rpk1)
const dh1 = x448DH(sk2, rpk1)
const dh2 = x448DH(sk1, rpk2)
const dh3 = x448DH(sk2, rpk2)
return pqX3dh(sk1, rk1Pub, dh1, dh2, dh3, kemAccepted)
}
// -- rootKdf (lines 1159-1166)
export function rootKdf(
rk: Uint8Array, // RatchetKey (32 bytes)
peerPubKey: Uint8Array, // PublicKey a (raw, 56 bytes for X448)
ownPrivKey: Uint8Array, // PrivateKey a (raw, 56 bytes for X448)
kemSecret: Uint8Array | null, // Maybe KEMSharedKey
): {rk: Uint8Array; ck: Uint8Array; nhk: Uint8Array} {
const dhOut = x448DH(peerPubKey, ownPrivKey)
const ss = kemSecret ? concatBytes(dhOut, kemSecret) : dhOut
const [rk_, ck, nhk] = hkdf3(rk, ss, "SimpleXRootRatchet")
return {rk: rk_, ck, nhk}
}
// -- chainKdf (lines 1168-1172)
export function chainKdf(ck: Uint8Array): {ck: Uint8Array; mk: Uint8Array; iv: Uint8Array; ehIV: Uint8Array} {
const EMPTY = new Uint8Array(0)
const [ck_, mk, ivs] = hkdf3(EMPTY, ck, "SimpleXChainRatchet")
return {ck: ck_, mk, iv: ivs.slice(0, 16), ehIV: ivs.slice(16, 32)}
}
// -- Header padding (lines 716-719)
export function paddedHeaderLen(v: number, pqSupport: boolean): number {
if (pqSupport && v >= pqRatchetE2EEncryptVersion) return 2310
return 88
}
// -- SndRatchet (lines 554-559)
export interface SndRatchet {
rcDHRr: Uint8Array // peer's public key (raw, 56 bytes)
rcCKs: Uint8Array // sending chain key (32 bytes)
rcHKs: Uint8Array // sending header key (32 bytes)
}
// -- RcvRatchet (lines 561-565)
export interface RcvRatchet {
rcCKr: Uint8Array // receiving chain key (32 bytes)
rcHKr: Uint8Array // receiving header key (32 bytes)
}
// -- MessageKey (lines 608-609)
export interface MessageKey {
mk: Uint8Array // Key (32 bytes)
iv: Uint8Array // IV (16 bytes)
}
// -- RatchetVersions (lines 534-538)
export interface RatchetVersions {
current: number
maxSupported: number
}
// -- Ratchet (lines 512-532)
export interface Ratchet {
rcVersion: RatchetVersions
rcAD: Uint8Array // Str (associated data, raw bytes)
rcDHRs: Uint8Array // PrivateKey a (raw, 56 bytes)
rcKEM: RatchetKEM | null
rcSupportKEM: boolean // PQSupport
rcEnableKEM: boolean // PQEncryption
rcSndKEM: boolean // PQEncryption
rcRcvKEM: boolean // PQEncryption
rcRK: Uint8Array // RatchetKey (32 bytes)
rcSnd: SndRatchet | null
rcRcv: RcvRatchet | null
rcNs: number // Word32
rcNr: number // Word32
rcPN: number // Word32
rcNHKs: Uint8Array // HeaderKey (32 bytes)
rcNHKr: Uint8Array // HeaderKey (32 bytes)
}
// -- SkippedMsgKeys (lines 580-582)
export type SkippedMsgKeys = Map<string, Map<number, MessageKey>>
const MAX_SKIP = 512
function hexKey(k: Uint8Array): string {
return Array.from(k, b => b.toString(16).padStart(2, "0")).join("")
}
function hexToBytes(hex: string): Uint8Array {
const bytes = new Uint8Array(hex.length / 2)
for (let i = 0; i < hex.length; i += 2) bytes[i / 2] = parseInt(hex.substring(i, i + 2), 16)
return bytes
}
// -- initSndRatchet (lines 643-666)
export function initSndRatchet(
rcVersion: RatchetVersions,
rcDHRr: Uint8Array, // peer's public key (raw)
rcDHRs: Uint8Array, // our private key (raw)
initParams: RatchetInitParams,
rcPQRs_: KEMKeyPair | null,
): Ratchet {
const {assocData, ratchetKey, sndHK, rcvNextHK, kemAccepted} = initParams
// state.RK, state.CKs, state.NHKs = KDF_RK_HE(SK, DH(state.DHRs, state.DHRr) || state.PQRss)
const kemSecret = kemAccepted ? kemAccepted.rcPQRss : null
const {rk: rcRK, ck: rcCKs, nhk: rcNHKs} = rootKdf(ratchetKey, rcDHRr, rcDHRs, kemSecret)
const pqOn = rcPQRs_ !== null
return {
rcVersion,
rcAD: assocData,
rcDHRs,
rcKEM: rcPQRs_ ? {rcPQRs: rcPQRs_, rcKEMs: kemAccepted} : null,
rcSupportKEM: pqOn,
rcEnableKEM: pqOn,
rcSndKEM: kemAccepted !== null,
rcRcvKEM: false,
rcRK,
rcSnd: {rcDHRr, rcCKs, rcHKs: sndHK},
rcRcv: null,
rcPN: 0,
rcNs: 0,
rcNr: 0,
rcNHKs,
rcNHKr: rcvNextHK,
}
}
// -- initRcvRatchet (lines 674-699)
export function initRcvRatchet(
rcVersion: RatchetVersions,
rcDHRs: Uint8Array, // our private key (raw)
initParams: RatchetInitParams,
rcPQRs_: KEMKeyPair | null,
pqSupport: boolean,
): Ratchet {
const {assocData, ratchetKey, sndHK, rcvNextHK, kemAccepted} = initParams
return {
rcVersion,
rcAD: assocData,
rcDHRs,
rcKEM: rcPQRs_ ? {rcPQRs: rcPQRs_, rcKEMs: kemAccepted} : null,
rcSupportKEM: pqSupport,
rcEnableKEM: pqSupport,
rcSndKEM: false,
rcRcvKEM: false,
rcRK: ratchetKey,
rcSnd: null,
rcRcv: null,
rcPN: 0,
rcNs: 0,
rcNr: 0,
rcNHKs: rcvNextHK,
rcNHKr: sndHK,
}
}
// -- RKEMParams (lines 188-190) - parsed KEM params from message header
export type RKEMParams =
| {type: "proposed", kemPk: Uint8Array} // RKParamsProposed KEMPublicKey
| {type: "accepted", kemCt: Uint8Array, kemPk: Uint8Array} // RKParamsAccepted KEMCiphertext KEMPublicKey
// -- MsgHeader (lines 703-711)
interface MsgHeader {
msgMaxVersion: number
msgDHRs: Uint8Array // PublicKey a (raw, 56 bytes)
msgKEM: RKEMParams | null
msgPN: number // Word32
msgNs: number // Word32
}
// -- encodeMsgHeader (lines 727-730)
function encodeMsgHeader(v: number, hdr: MsgHeader): Uint8Array {
const vBytes = new Uint8Array(2)
vBytes[0] = (hdr.msgMaxVersion >> 8) & 0xff
vBytes[1] = hdr.msgMaxVersion & 0xff
const dhDer = encodePubKeyX448(hdr.msgDHRs)
const pn = encodeWord32(hdr.msgPN)
const ns = encodeWord32(hdr.msgNs)
if (v >= pqRatchetE2EEncryptVersion) {
// smpEncode (msgMaxVersion, msgDHRs, msgKEM, msgPN, msgNs)
// msgKEM :: Maybe ARKEMParams
const kemBytes = hdr.msgKEM ? encodeRKEMParams(hdr.msgKEM) : new Uint8Array([0x30]) // Nothing
return concatBytes(vBytes, encodeBytes(dhDer), kemBytes, pn, ns)
}
// smpEncode (msgMaxVersion, msgDHRs, msgPN, msgNs)
return concatBytes(vBytes, encodeBytes(dhDer), pn, ns)
}
// Encode Maybe ARKEMParams: '1' + encoded params, or nothing (handled at call site with '0')
function encodeRKEMParams(params: RKEMParams): Uint8Array {
if (params.type === "proposed") {
// Just ('P', kemPk) - smpEncode ('P', k) where k is KEMPublicKey (Large)
return concatBytes(new Uint8Array([0x31, 0x50]), encodeLarge(params.kemPk))
}
// Just ('A', ct, kemPk) - smpEncode ('A', ct, k)
return concatBytes(new Uint8Array([0x31, 0x41]), encodeLarge(params.kemCt), encodeLarge(params.kemPk))
}
function encodeWord32(n: number): Uint8Array {
const buf = new Uint8Array(4)
buf[0] = (n >> 24) & 0xff; buf[1] = (n >> 16) & 0xff
buf[2] = (n >> 8) & 0xff; buf[3] = n & 0xff
return buf
}
// -- msgHeaderP (lines 733-740)
function decodeMsgHeader(v: number, data: Uint8Array): MsgHeader {
const d = new Decoder(data)
const msgMaxVersion = decodeWord16(d)
const dhDer = decodeBytes(d)
const msgDHRs = decodePubKeyX448(dhDer)
let msgKEM: RKEMParams | null = null
if (v >= pqRatchetE2EEncryptVersion) {
// Maybe ARKEMParams
const maybeByte = d.anyByte()
if (maybeByte === 0x31) {
// Just - parse ARKEMParams
const tag = d.anyByte()
if (tag === 0x50) { // 'P' - Proposed: KEMPublicKey (Large)
msgKEM = {type: "proposed", kemPk: decodeLarge(d)}
} else if (tag === 0x41) { // 'A' - Accepted: KEMCiphertext (Large) + KEMPublicKey (Large)
const kemCt = decodeLarge(d)
const kemPk = decodeLarge(d)
msgKEM = {type: "accepted", kemCt, kemPk}
} else {
throw new Error("decodeMsgHeader: unknown KEM tag " + tag)
}
}
// else '0' = Nothing, msgKEM stays null
}
const msgPN = decodeWord32(d)
const msgNs = decodeWord32(d)
return {msgMaxVersion, msgDHRs, msgKEM, msgPN, msgNs}
}
// -- EncMessageHeader (lines 742-756)
interface EncMessageHeader {
ehVersion: number // current ratchet version
ehIV: Uint8Array // IV (raw 16 bytes)
ehAuthTag: Uint8Array // AuthTag (raw 16 bytes)
ehBody: Uint8Array // encrypted header body
}
// smpEncode (lines 751-752)
function encodeEncMessageHeader(emh: EncMessageHeader): Uint8Array {
const vBytes = new Uint8Array(2)
vBytes[0] = (emh.ehVersion >> 8) & 0xff
vBytes[1] = emh.ehVersion & 0xff
// smpEncode (ehVersion, ehIV, ehAuthTag) <> encodeLarge ehVersion ehBody
const bodyEnc = emh.ehVersion >= pqRatchetE2EEncryptVersion
? encodeLarge(emh.ehBody)
: encodeBytes(emh.ehBody)
return concatBytes(vBytes, emh.ehIV, emh.ehAuthTag, bodyEnc)
}
// smpP (lines 753-756)
function decodeEncMessageHeader(data: Uint8Array): EncMessageHeader {
const d = new Decoder(data)
const ehVersion = decodeWord16(d)
const ehIV = d.take(16) // IV is raw 16 bytes
const ehAuthTag = d.take(16) // AuthTag is raw 16 bytes
// largeP: peek first byte, if < 32 then Large (2-byte len), else ByteString (1-byte len)
const firstByte = data[d.offset()]
const ehBody = firstByte < 32 ? decodeLarge(d) : decodeBytes(d)
return {ehVersion, ehIV, ehAuthTag, ehBody}
}
// -- EncRatchetMessage (lines 772-787)
interface EncRatchetMessage {
emHeader: Uint8Array // smpEncoded EncMessageHeader
emAuthTag: Uint8Array // AuthTag (raw 16 bytes)
emBody: Uint8Array // encrypted message body
}
// encodeEncRatchetMessage (lines 779-781)
function encodeEncRatchetMessage(v: number, msg: EncRatchetMessage): Uint8Array {
// encodeLarge v emHeader <> smpEncode (emAuthTag, Tail emBody)
const headerEnc = v >= pqRatchetE2EEncryptVersion
? encodeLarge(msg.emHeader)
: encodeBytes(msg.emHeader)
return concatBytes(headerEnc, msg.emAuthTag, msg.emBody)
}
// encRatchetMessageP (lines 783-787)
function decodeEncRatchetMessage(data: Uint8Array): EncRatchetMessage {
const d = new Decoder(data)
// largeP
const firstByte = data[d.offset()]
const emHeader = firstByte < 32 ? decodeLarge(d) : decodeBytes(d)
// smpEncode (emAuthTag, Tail emBody) → raw 16 bytes + rest
const emAuthTag = d.take(16)
const emBody = d.takeAll()
return {emHeader, emAuthTag, emBody}
}
// -- MsgEncryptKey (lines 962-968)
interface MsgEncryptKey {
msgRcVersion: number
msgKey: MessageKey
msgRcAD: Uint8Array
msgEncHeader: Uint8Array
}
// -- msgKEMParams (lines 956-958) - build KEM params from ratchet state for message header
function msgKEMParams(kem: RatchetKEM): RKEMParams {
const {rcPQRs, rcKEMs} = kem
if (!rcKEMs) {
return {type: "proposed", kemPk: rcPQRs.publicKey}
}
return {type: "accepted", kemCt: rcKEMs.rcPQRct, kemPk: rcPQRs.publicKey}
}
// -- pqEnableSupport (line 836-837)
function pqEnableSupport(v: number, sup: boolean, enc: boolean): boolean {
return sup || (v >= pqRatchetE2EEncryptVersion && enc)
}
// -- rcEncryptHeader + rcEncryptMsg (lines 902-975)
export interface EncryptResult {
ciphertext: Uint8Array
state: Ratchet
}
export function rcEncrypt(
rc: Ratchet,
plaintext: Uint8Array,
paddedMsgLen: number,
): EncryptResult {
if (!rc.rcSnd) throw new Error("rcEncrypt: no sending ratchet (CERatchetState)")
const snd = rc.rcSnd
const v = rc.rcVersion.current
// state.CKs, mk = KDF_CK(state.CKs)
const chain = chainKdf(snd.rcCKs)
// header
const headerPlain = encodeMsgHeader(v, {
msgMaxVersion: rc.rcVersion.maxSupported,
msgDHRs: x448.getPublicKey(rc.rcDHRs),
msgKEM: rc.rcKEM ? msgKEMParams(rc.rcKEM) : null,
msgPN: rc.rcPN,
msgNs: rc.rcNs,
})
// enc_header = HENCRYPT(state.HKs, header)
const phl = paddedHeaderLen(v, rc.rcSupportKEM)
const {authTag: ehAuthTag, ciphertext: ehBody} = encryptAEAD(snd.rcHKs, chain.ehIV, phl, rc.rcAD, headerPlain)
// smpEncode EncMessageHeader
const emHeader = encodeEncMessageHeader({ehVersion: v, ehBody, ehAuthTag, ehIV: chain.ehIV})
// ENCRYPT(mk, plaintext, CONCAT(AD, enc_header))
const bodyAD = concatBytes(rc.rcAD, emHeader)
const {authTag: emAuthTag, ciphertext: emBody} = encryptAEAD(chain.mk, chain.iv, paddedMsgLen, bodyAD, plaintext)
// encodeEncRatchetMessage
const ciphertext = encodeEncRatchetMessage(v, {emHeader, emBody, emAuthTag})
// Update state
const newState: Ratchet = {
...rc,
rcSnd: {...snd, rcCKs: chain.ck},
rcNs: rc.rcNs + 1,
}
return {ciphertext, state: newState}
}
// -- rcDecrypt (lines 990-1157)
export interface DecryptResult {
plaintext: Uint8Array
state: Ratchet
skippedKeys: SkippedMsgKeys
}
export function rcDecrypt(
rc: Ratchet,
skippedKeys: SkippedMsgKeys,
ciphertext: Uint8Array,
): DecryptResult {
const encMsg = decodeEncRatchetMessage(ciphertext)
const encHdr = decodeEncMessageHeader(encMsg.emHeader)
// TrySkippedMessageKeysHE
const skipped = tryDecryptSkipped(rc, skippedKeys, encHdr, encMsg)
if (skipped) return skipped
// DecryptHeader
let ratchetStep: "same" | "advance" = "advance"
let hdr: MsgHeader | null = null
if (rc.rcRcv) {
hdr = tryDecryptHeader(rc.rcRcv.rcHKr, rc.rcAD, encHdr)
if (hdr) ratchetStep = "same"
}
if (!hdr) {
hdr = tryDecryptHeader(rc.rcNHKr, rc.rcAD, encHdr)
if (!hdr) throw new Error("rcDecrypt: header decryption failed (CERatchetHeader)")
ratchetStep = "advance"
}
// Version upgrade
let state = rc
const {current, maxSupported} = rc.rcVersion
if (hdr.msgMaxVersion > current) {
state = {...state, rcVersion: {...state.rcVersion, current: Math.max(current, Math.min(hdr.msgMaxVersion, maxSupported))}}
}
let newSkipped = new Map(skippedKeys)
if (ratchetStep === "advance") {
// SkipMessageKeysHE(state, header.pn)
const skip1 = skipMessageKeys(state, newSkipped, hdr.msgPN)
state = skip1.state; newSkipped = skip1.skippedKeys
// DHRatchetPQ2HE(state, header) - ratchet step (lines 1043-1071)
const {kemSS, kemSS2, rcKEM: rcKEM_} = pqRatchetStep(state, hdr.msgKEM)
const newDHRs = generateX448KeyPair()
// state.RK, state.CKr, state.NHKr = KDF_RK_HE(state.RK, DH(state.DHRs, state.DHRr) || ss)
const kdf1 = rootKdf(state.rcRK, hdr.msgDHRs, state.rcDHRs, kemSS)
// state.RK, state.CKs, state.NHKs = KDF_RK_HE(state.RK, DH(state.DHRs', state.DHRr) || state.PQRss)
const kdf2 = rootKdf(kdf1.rk, hdr.msgDHRs, newDHRs.privateKey, kemSS2)
const sndKEM = kemSS2 !== null
const rcvKEM = kemSS !== null
const rcEnableKEM_ = sndKEM || rcvKEM || rcKEM_ !== null
state = {
...state,
rcDHRs: newDHRs.privateKey,
rcKEM: rcKEM_,
rcSupportKEM: pqEnableSupport(state.rcVersion.current, state.rcSupportKEM, rcEnableKEM_),
rcEnableKEM: rcEnableKEM_,
rcSndKEM: sndKEM,
rcRcvKEM: rcvKEM,
rcRK: kdf2.rk,
rcSnd: {rcDHRr: hdr.msgDHRs, rcCKs: kdf2.ck, rcHKs: state.rcNHKs},
rcRcv: {rcCKr: kdf1.ck, rcHKr: state.rcNHKr},
rcPN: rc.rcNs,
rcNs: 0,
rcNr: 0,
rcNHKs: kdf2.nhk,
rcNHKr: kdf1.nhk,
}
}
// SkipMessageKeysHE(state, header.n)
const skip2 = skipMessageKeys(state, newSkipped, hdr.msgNs)
state = skip2.state; newSkipped = skip2.skippedKeys
if (!state.rcRcv) throw new Error("rcDecrypt: no receiving ratchet after skip")
// state.CKr, mk = KDF_CK(state.CKr)
const chain = chainKdf(state.rcRcv.rcCKr)
// DECRYPT(mk, cipher-text, CONCAT(AD, enc_header))
const bodyAD = concatBytes(state.rcAD, encMsg.emHeader)
const plaintext = decryptAEAD(chain.mk, chain.iv, bodyAD, encMsg.emBody, encMsg.emAuthTag)
// state.Nr += 1
state = {
...state,
rcRcv: {...state.rcRcv, rcCKr: chain.ck},
rcNr: state.rcNr + 1,
}
return {plaintext, state, skippedKeys: newSkipped}
}
// -- skipMessageKeys (lines 1105-1121)
function skipMessageKeys(
rc: Ratchet,
skippedKeys: SkippedMsgKeys,
untilN: number,
): {state: Ratchet; skippedKeys: SkippedMsgKeys} {
if (!rc.rcRcv) return {state: rc, skippedKeys}
const rcv = rc.rcRcv
const rcNr = rc.rcNr
if (rcNr > untilN + 1) throw new Error("rcDecrypt: earlier message (CERatchetEarlierMessage)")
if (rcNr === untilN + 1) throw new Error("rcDecrypt: duplicate message (CERatchetDuplicateMessage)")
if (rcNr + MAX_SKIP < untilN) throw new Error("rcDecrypt: too many skipped (CERatchetTooManySkipped)")
if (rcNr === untilN) return {state: rc, skippedKeys}
// advanceRcvRatchet
let ck = rcv.rcCKr
let nr = rcNr
const hkHex = hexKey(rcv.rcHKr)
const msgKeys = new Map(skippedKeys.get(hkHex) || new Map())
while (nr < untilN) {
const chain = chainKdf(ck)
msgKeys.set(nr, {mk: chain.mk, iv: chain.iv})
ck = chain.ck
nr++
}
const newSkipped = new Map(skippedKeys)
newSkipped.set(hkHex, msgKeys)
return {
state: {...rc, rcRcv: {...rcv, rcCKr: ck}, rcNr: nr},
skippedKeys: newSkipped,
}
}
// -- tryDecryptSkipped (lines 1122-1141)
function tryDecryptSkipped(
rc: Ratchet,
skippedKeys: SkippedMsgKeys,
encHdr: EncMessageHeader,
encMsg: EncRatchetMessage,
): DecryptResult | null {
for (const [hkHex, msgKeys] of skippedKeys) {
const hk = hexToBytes(hkHex)
const hdr = tryDecryptHeader(hk, rc.rcAD, encHdr)
if (hdr) {
const mk = msgKeys.get(hdr.msgNs)
if (mk) {
const bodyAD = concatBytes(rc.rcAD, encMsg.emHeader)
const plaintext = decryptAEAD(mk.mk, mk.iv, bodyAD, encMsg.emBody, encMsg.emAuthTag)
const newMsgKeys = new Map(msgKeys)
newMsgKeys.delete(hdr.msgNs)
const newSkipped = new Map(skippedKeys)
if (newMsgKeys.size === 0) newSkipped.delete(hkHex)
else newSkipped.set(hkHex, newMsgKeys)
return {plaintext, state: rc, skippedKeys: newSkipped}
}
// Header decrypted but msgNs not in skipped keys - check if same/advance ratchet
// For now, fall through to normal decrypt
}
}
return null
}
// -- pqRatchetStep (lines 1072-1104)
// Returns (kemSS for receive rootKdf, kemSS' for send rootKdf, new RatchetKEM state)
function pqRatchetStep(
rc: Ratchet,
msgKEM: RKEMParams | null,
): {kemSS: Uint8Array | null; kemSS2: Uint8Array | null; rcKEM: RatchetKEM | null} {
const pqEnc = rc.rcEnableKEM
const v = rc.rcVersion.current
if (!msgKEM) {
// Received message does not have KEM in header
if (!rc.rcKEM && pqEnc && v >= pqRatchetE2EEncryptVersion) {
// User enabled KEM but no KEM state yet - generate new keypair
const rcPQRs = sntrup761Keypair()
return {kemSS: null, kemSS2: null, rcKEM: {rcPQRs, rcKEMs: null}}
}
return {kemSS: null, kemSS2: null, rcKEM: null}
}
// Received message has KEM in header
if (pqEnc && v >= pqRatchetE2EEncryptVersion) {
// Get shared secret from received KEM params
const {ss, rcPQRr} = kemSharedSecret(rc.rcKEM, msgKEM)
// state.PQRct = PQKEM-ENC(state.PQRr, state.PQRss)
const kemEncResult = sntrup761Enc(rcPQRr)
// state.PQRs = GENERATE_PQKEM()
const rcPQRs = sntrup761Keypair()
const kem: RatchetKEM = {
rcPQRs,
rcKEMs: {rcPQRr, rcPQRss: kemEncResult.sharedSecret, rcPQRct: kemEncResult.ciphertext},
}
return {kemSS: ss, kemSS2: kemEncResult.sharedSecret, rcKEM: kem}
}
// PQ not enabled but message has KEM - extract shared secret only (no new KEM state)
const {ss} = kemSharedSecret(rc.rcKEM, msgKEM)
return {kemSS: ss, kemSS2: null, rcKEM: null}
}
// Extract shared secret from received KEM params (lines 1097-1104)
function kemSharedSecret(
rcKEM: RatchetKEM | null,
params: RKEMParams,
): {ss: Uint8Array | null; rcPQRr: Uint8Array} {
if (params.type === "proposed") {
// RKParamsProposed k -> no shared secret yet, just received the public key
return {ss: null, rcPQRr: params.kemPk}
}
// RKParamsAccepted ct k -> decapsulate ct with our private KEM key
if (!rcKEM) throw new Error("pqRatchetStep: CERatchetKEMState - no KEM state for accepted params")
const ss = sntrup761Dec(params.kemCt, rcKEM.rcPQRs.secretKey)
return {ss, rcPQRr: params.kemPk}
}
// -- decryptHeader helper (lines 1151-1153)
function tryDecryptHeader(headerKey: Uint8Array, ad: Uint8Array, encHdr: EncMessageHeader): MsgHeader | null {
try {
const plainHeader = decryptAEAD(headerKey, encHdr.ehIV, ad, encHdr.ehBody, encHdr.ehAuthTag)
return decodeMsgHeader(encHdr.ehVersion, plainHeader)
} catch {
return null
}
}
+89
View File
@@ -0,0 +1,89 @@
// SNTRUP761 post-quantum KEM.
// Mirrors: Simplex.Messaging.Crypto.SNTRUP761
//
// Uses WASM compiled from the same C source as the Haskell build
// (cbits/sntrup761.c by djb et al., public domain).
// SHA-512 from SUPERCOP/NaCl (djb, public domain).
// Key sizes (from sntrup761.h)
export const SNTRUP761_PUBLICKEY_SIZE = 1158
export const SNTRUP761_SECRETKEY_SIZE = 1763
export const SNTRUP761_CIPHERTEXT_SIZE = 1039
export const SNTRUP761_SIZE = 32 // shared secret
export interface KEMKeyPair {
publicKey: Uint8Array // 1158 bytes
secretKey: Uint8Array // 1763 bytes
}
export interface KEMEncResult {
ciphertext: Uint8Array // 1039 bytes
sharedSecret: Uint8Array // 32 bytes
}
// WASM module instance
let wasmModule: any = null
export async function initSntrup761(): Promise<void> {
if (wasmModule) return
const createSntrup761 = (await import("../../dist/wasm/sntrup761.mjs")).default
wasmModule = await createSntrup761()
}
function getModule(): any {
if (!wasmModule) throw new Error("sntrup761 WASM not initialized - call initSntrup761() first")
return wasmModule
}
export function sntrup761Keypair(): KEMKeyPair {
const m = getModule()
const pkPtr = m._malloc(SNTRUP761_PUBLICKEY_SIZE)
const skPtr = m._malloc(SNTRUP761_SECRETKEY_SIZE)
try {
m._sntrup761_wasm_keypair(pkPtr, skPtr)
const publicKey = new Uint8Array(m.HEAPU8.buffer, pkPtr, SNTRUP761_PUBLICKEY_SIZE).slice()
const secretKey = new Uint8Array(m.HEAPU8.buffer, skPtr, SNTRUP761_SECRETKEY_SIZE).slice()
return {publicKey, secretKey}
} finally {
m._free(pkPtr)
m._free(skPtr)
}
}
export function sntrup761Enc(publicKey: Uint8Array): KEMEncResult {
if (publicKey.length !== SNTRUP761_PUBLICKEY_SIZE) throw new Error("bad public key length")
const m = getModule()
const pkPtr = m._malloc(SNTRUP761_PUBLICKEY_SIZE)
const ctPtr = m._malloc(SNTRUP761_CIPHERTEXT_SIZE)
const ssPtr = m._malloc(SNTRUP761_SIZE)
try {
m.HEAPU8.set(publicKey, pkPtr)
m._sntrup761_wasm_enc(ctPtr, ssPtr, pkPtr)
const ciphertext = new Uint8Array(m.HEAPU8.buffer, ctPtr, SNTRUP761_CIPHERTEXT_SIZE).slice()
const sharedSecret = new Uint8Array(m.HEAPU8.buffer, ssPtr, SNTRUP761_SIZE).slice()
return {ciphertext, sharedSecret}
} finally {
m._free(pkPtr)
m._free(ctPtr)
m._free(ssPtr)
}
}
export function sntrup761Dec(ciphertext: Uint8Array, secretKey: Uint8Array): Uint8Array {
if (ciphertext.length !== SNTRUP761_CIPHERTEXT_SIZE) throw new Error("bad ciphertext length")
if (secretKey.length !== SNTRUP761_SECRETKEY_SIZE) throw new Error("bad secret key length")
const m = getModule()
const ctPtr = m._malloc(SNTRUP761_CIPHERTEXT_SIZE)
const skPtr = m._malloc(SNTRUP761_SECRETKEY_SIZE)
const ssPtr = m._malloc(SNTRUP761_SIZE)
try {
m.HEAPU8.set(ciphertext, ctPtr)
m.HEAPU8.set(secretKey, skPtr)
m._sntrup761_wasm_dec(ssPtr, ctPtr, skPtr)
return new Uint8Array(m.HEAPU8.buffer, ssPtr, SNTRUP761_SIZE).slice()
} finally {
m._free(ctPtr)
m._free(skPtr)
m._free(ssPtr)
}
}
+252 -1
View File
@@ -4,8 +4,12 @@
import {
Decoder, concatBytes,
encodeBytes, decodeBytes,
encodeLarge, decodeLarge
encodeLarge, decodeLarge,
encodeWord16, decodeWord16,
encodeBool, decodeBool,
encodeMaybe, decodeMaybe,
} from "@simplex-chat/xftp-web/dist/protocol/encoding.js"
import {cbEncrypt, cbDecrypt} from "@simplex-chat/xftp-web/dist/crypto/secretbox.js"
import {readTag, readSpace} from "@simplex-chat/xftp-web/dist/protocol/commands.js"
// -- Transmission encoding (Protocol.hs:2201-2203)
@@ -83,7 +87,12 @@ export function decodeLNK(d: Decoder): LNKResponse {
export type SMPResponse =
| {type: "LNK", response: LNKResponse}
| {type: "IDS", response: IDSResponse}
| {type: "MSG", response: MSGResponse}
| {type: "OK"}
| {type: "PONG"}
| {type: "END"}
| {type: "DELD"}
| {type: "ERR", message: string}
export function decodeResponse(d: Decoder): SMPResponse {
@@ -93,7 +102,18 @@ export function decodeResponse(d: Decoder): SMPResponse {
readSpace(d)
return {type: "LNK", response: decodeLNK(d)}
}
case "IDS": {
readSpace(d)
return {type: "IDS", response: decodeIDS(d)}
}
case "MSG": {
readSpace(d)
return {type: "MSG", response: decodeMSG(d)}
}
case "OK": return {type: "OK"}
case "PONG": return {type: "PONG"}
case "END": return {type: "END"}
case "DELD": return {type: "DELD"}
case "ERR": {
readSpace(d)
return {type: "ERR", message: readTag(d)}
@@ -101,3 +121,234 @@ export function decodeResponse(d: Decoder): SMPResponse {
default: throw new Error("unknown SMP response: " + tag)
}
}
// -- SMP command encoders (Protocol.hs:1679-1715)
// MsgFlags (Protocol.hs:884-892)
// Single byte: Bool encoding of notification flag
export function encodeMsgFlags(notification: boolean): Uint8Array {
return encodeBool(notification)
}
// SubscriptionMode (Protocol.hs:651-659)
// 'S' = SMSubscribe, 'C' = SMOnlyCreate
export function encodeSubMode(subscribe: boolean): Uint8Array {
return ascii(subscribe ? "S" : "C")
}
// NEW (Protocol.hs:1682-1689)
// For v19: e(NEW_, ' ', rKey, dhKey) <> e(auth_, subMode, queueReqData, ntfCreds)
// auth_ = Maybe SndPublicAuthKey (DER-encoded)
// queueReqData = Maybe QueueReqData
// ntfCreds = Maybe NewNtfCreds (not needed for widget)
export function encodeNEW(
rcvAuthKey: Uint8Array, // DER-encoded Ed25519 or X25519 public key
rcvDhKey: Uint8Array, // DER-encoded X25519 public key
sndAuthKey: Uint8Array | null, // DER-encoded, for TOFU sender auth
subscribe: boolean,
): Uint8Array {
return concatBytes(
ascii("NEW "),
encodeBytes(rcvAuthKey),
encodeBytes(rcvDhKey),
encodeMaybe(encodeBytes, sndAuthKey),
encodeSubMode(subscribe),
encodeMaybe(() => new Uint8Array(0), null), // queueReqData = Nothing (widget doesn't create links)
encodeMaybe(() => new Uint8Array(0), null), // ntfCreds = Nothing
)
}
// KEY (Protocol.hs:1692)
// KEY k -> e(KEY_, ' ', k)
export function encodeKEY(senderKey: Uint8Array): Uint8Array {
return concatBytes(ascii("KEY "), encodeBytes(senderKey))
}
// SKEY (Protocol.hs:1703)
// SKEY k -> e(SKEY_, ' ', k)
export function encodeSKEY(senderKey: Uint8Array): Uint8Array {
return concatBytes(ascii("SKEY "), encodeBytes(senderKey))
}
// SUB (Protocol.hs:1690)
export function encodeSUB(): Uint8Array {
return ascii("SUB")
}
// ACK (Protocol.hs:1699)
// ACK msgId -> e(ACK_, ' ', msgId)
export function encodeACK(msgId: Uint8Array): Uint8Array {
return concatBytes(ascii("ACK "), encodeBytes(msgId))
}
// SEND (Protocol.hs:1704)
// SEND flags msg -> e(SEND_, ' ', flags, ' ', Tail msg)
export function encodeSEND(notification: boolean, msgBody: Uint8Array): Uint8Array {
return concatBytes(
ascii("SEND "),
encodeMsgFlags(notification),
ascii(" "),
msgBody, // Tail - no length prefix
)
}
// OFF (Protocol.hs:1700)
export function encodeOFF(): Uint8Array {
return ascii("OFF")
}
// DEL (Protocol.hs:1701)
export function encodeDEL(): Uint8Array {
return ascii("DEL")
}
// -- SMP response decoders
// IDS (Protocol.hs:1914-1921)
// For v19: e(IDS_, ' ', rcvId, sndId, srvDh) <> e(queueMode, linkId, serviceId, ntfCreds)
export interface IDSResponse {
rcvId: Uint8Array
sndId: Uint8Array
srvDhKey: Uint8Array
queueMode: string | null // 'M' = Messaging, 'C' = Contact
linkId: Uint8Array | null
}
export function decodeIDS(d: Decoder): IDSResponse {
const rcvId = decodeBytes(d)
const sndId = decodeBytes(d)
const srvDhKey = decodeBytes(d)
// v19: queueMode (Maybe QueueMode), linkId (Maybe ByteString), serviceId, ntfCreds
// QueueMode is encoded as Maybe Char ('M'/'C'), not Maybe ByteString
let queueMode: string | null = null
if (d.remaining() > 0) {
const qmByte = d.anyByte()
if (qmByte === 0x31) { // '1' = Just
queueMode = String.fromCharCode(d.anyByte())
}
// '0' = Nothing, queueMode stays null
}
let linkId: Uint8Array | null = null
if (d.remaining() > 0) {
linkId = decodeMaybe(decodeBytes, d)
}
// serviceId and ntfCreds - skip remaining
return {rcvId, sndId, srvDhKey, queueMode, linkId}
}
// MSG (Protocol.hs:1927-1928)
// MSG RcvMessage {msgId, msgBody = EncRcvMsgBody body} -> e(MSG_, ' ', msgId, Tail body)
export interface MSGResponse {
msgId: Uint8Array
msgBody: Uint8Array
}
export function decodeMSG(d: Decoder): MSGResponse {
const msgId = decodeBytes(d)
const msgBody = d.takeAll()
return {msgId, msgBody}
}
// -- Per-queue E2E encryption (Protocol.hs:1071-1114)
// Protocol.hs:316-320
export const e2eEncMessageLength = 16000
export const e2eEncConfirmationLength = 15904
// Protocol.hs:1078-1086
export interface PubHeader {
phVersion: number // VersionSMPC (Word16)
phE2ePubDhKey: Uint8Array | null // Maybe PublicKeyX25519 (DER-encoded ByteString)
}
export function encodePubHeader(h: PubHeader): Uint8Array {
return concatBytes(encodeWord16(h.phVersion), encodeMaybe(encodeBytes, h.phE2ePubDhKey))
}
export function decodePubHeader(d: Decoder): PubHeader {
return {phVersion: decodeWord16(d), phE2ePubDhKey: decodeMaybe(decodeBytes, d)}
}
// Protocol.hs:1097-1110
export type PrivHeader =
| {type: "PHConfirmation", key: Uint8Array} // 'K' + DER-encoded APublicAuthKey
| {type: "PHEmpty"} // '_'
export function encodePrivHeader(h: PrivHeader): Uint8Array {
switch (h.type) {
case "PHConfirmation": return concatBytes(new Uint8Array([0x4B]), encodeBytes(h.key)) // 'K' + encodeBytes
case "PHEmpty": return new Uint8Array([0x5F]) // '_'
}
}
export function decodePrivHeader(d: Decoder): PrivHeader {
const tag = d.anyByte()
switch (tag) {
case 0x4B: return {type: "PHConfirmation", key: decodeBytes(d)} // 'K'
case 0x5F: return {type: "PHEmpty"} // '_'
default: throw new Error("decodePrivHeader: unknown tag " + tag)
}
}
// Protocol.hs:1095, 1112-1114
export interface ClientMessage {
privHeader: PrivHeader
body: Uint8Array
}
// smpEncode (ClientMessage h msg) = smpEncode h <> msg
export function encodeClientMessage(msg: ClientMessage): Uint8Array {
return concatBytes(encodePrivHeader(msg.privHeader), msg.body)
}
export function decodeClientMessage(d: Decoder): ClientMessage {
const privHeader = decodePrivHeader(d)
const body = d.takeAll()
return {privHeader, body}
}
// Protocol.hs:1071-1093
export interface ClientMsgEnvelope {
cmHeader: PubHeader
cmNonce: Uint8Array // CbNonce: raw 24 bytes
cmEncBody: Uint8Array // encrypted body (Tail)
}
// smpEncode (cmHeader, cmNonce, Tail cmEncBody)
export function encodeClientMsgEnvelope(env: ClientMsgEnvelope): Uint8Array {
return concatBytes(encodePubHeader(env.cmHeader), env.cmNonce, env.cmEncBody)
}
export function decodeClientMsgEnvelope(d: Decoder): ClientMsgEnvelope {
const cmHeader = decodePubHeader(d)
const cmNonce = d.take(24) // CbNonce is raw 24 bytes
const cmEncBody = d.takeAll()
return {cmHeader, cmNonce, cmEncBody}
}
// -- Per-queue E2E encrypt/decrypt (Agent/Client.hs:2074-2102)
// agentCbEncrypt: encrypt a ClientMessage and wrap in ClientMsgEnvelope
export function agentCbEncrypt(
e2eDhSecret: Uint8Array, // X25519 DH shared secret (32 bytes)
smpClientVersion: number, // Word16
e2ePubKey: Uint8Array | null, // DER-encoded X25519 public key, null for normal messages
msg: Uint8Array, // smpEncode(ClientMessage)
): Uint8Array {
const cmNonce = crypto.getRandomValues(new Uint8Array(24))
const paddedLen = e2ePubKey !== null ? e2eEncConfirmationLength : e2eEncMessageLength
const cmEncBody = cbEncrypt(e2eDhSecret, cmNonce, msg, paddedLen)
const cmHeader: PubHeader = {phVersion: smpClientVersion, phE2ePubDhKey: e2ePubKey}
return encodeClientMsgEnvelope({cmHeader, cmNonce, cmEncBody})
}
// agentCbDecrypt: decrypt a ClientMsgEnvelope
export function agentCbDecrypt(
dhSecret: Uint8Array, // X25519 DH shared secret (32 bytes)
data: Uint8Array, // raw ClientMsgEnvelope bytes
): {pubHeader: PubHeader, clientMessage: ClientMessage} {
const env = decodeClientMsgEnvelope(new Decoder(data))
const plaintext = cbDecrypt(dhSecret, env.cmNonce, env.cmEncBody)
const clientMessage = decodeClientMessage(new Decoder(plaintext))
return {pubHeader: env.cmHeader, clientMessage}
}
+326
View File
@@ -0,0 +1,326 @@
// Double ratchet REPL for cross-language testing.
// Holds one ratchet state, reads commands from stdin, writes results to stdout.
//
// Init protocol:
// INIT_RCV <version> <pqSupport 0|1>
// → ok: <hex E2E params>
// COMPLETE <hex peer E2E params>
// → ok
// INIT_SND <version> <kemMode> <hex peer E2E params>
// → ok: <hex E2E params>
// kemMode: none | propose | accept
//
// Encrypt/decrypt operators (same syntax as Haskell DoubleRatchetTests):
// \#> <plaintext> encrypt, assert noSndKEM
// !#> <plaintext> encrypt, assert hasSndKEM
// \#>! <plaintext> encrypt PQEncOn, assert noSndKEM
// !#>! <plaintext> encrypt PQEncOn, assert hasSndKEM
// !#>\ <plaintext> encrypt PQEncOff, assert hasSndKEM
// \#>\ <plaintext> encrypt PQEncOff, assert noSndKEM
// <#\ <hex ct> <expected> decrypt, assert noRcvKEM
// <#! <hex ct> <expected> decrypt, assert hasRcvKEM
//
// Plain encrypt/decrypt (no assertions):
// E <plaintext> → ok: <hex ciphertext>
// D <hex ciphertext> → ok: <plaintext>
//
// Response format: ok: <data> or error: <message>
import {createInterface} from "readline"
import {
generateX448KeyPair, pqX3dhSnd, pqX3dhRcv,
encodePubKeyX448, decodePubKeyX448,
initSndRatchet, initRcvRatchet,
rcEncrypt, rcDecrypt,
rootKdf,
type Ratchet, type SkippedMsgKeys, type RatchetVersions,
type RatchetInitParams, type RatchetKEMAccepted,
} from "../dist/crypto/ratchet.js"
import {initSntrup761, sntrup761Keypair, sntrup761Enc, sntrup761Dec} from "../dist/crypto/sntrup761.js"
import type {KEMKeyPair} from "../dist/crypto/sntrup761.js"
import {
Decoder, decodeBytes, decodeLarge, encodeBytes, encodeWord16, concatBytes,
} from "@simplex-chat/xftp-web/dist/protocol/encoding.js"
// -- State
let ratchet: Ratchet | null = null
let skippedKeys: SkippedMsgKeys = new Map()
const PADDED_MSG_LEN = 16000
// Intermediate state for RCV init (between INIT_RCV and COMPLETE)
let rcvInitState: {
privKey1: Uint8Array
privKey2: Uint8Array
kemKeyPair: KEMKeyPair | null
pqSupport: boolean
} | null = null
// -- Hex helpers
function toHex(bytes: Uint8Array): string {
return Array.from(bytes, b => b.toString(16).padStart(2, "0")).join("")
}
function fromHex(hex: string): Uint8Array {
const bytes = new Uint8Array(hex.length / 2)
for (let i = 0; i < hex.length; i += 2)
bytes[i / 2] = parseInt(hex.substring(i, i + 2), 16)
return bytes
}
// -- E2E params helpers
// Parse E2ERatchetParams: version(Word16) + pk1(ByteString) + pk2(ByteString) + Maybe KEMParams
interface ParsedE2EParams {
version: number
pk1Raw: Uint8Array // raw X448 public key
pk2Raw: Uint8Array // raw X448 public key
kemPk: Uint8Array | null // KEM public key if proposed
kemCt: Uint8Array | null // KEM ciphertext if accepted
kemAcceptPk: Uint8Array | null // KEM public key in accepted
}
function parseE2EParams(data: Uint8Array): ParsedE2EParams {
const d = new Decoder(data)
const version = d.anyByte() * 256 + d.anyByte()
const pk1Raw = decodePubKeyX448(decodeBytes(d))
const pk2Raw = decodePubKeyX448(decodeBytes(d))
let kemPk: Uint8Array | null = null
let kemCt: Uint8Array | null = null
let kemAcceptPk: Uint8Array | null = null
if (version >= 3 && d.remaining() > 0) {
const maybeByte = d.anyByte()
if (maybeByte === 0x31) { // Just
const tag = d.anyByte()
if (tag === 0x50) { // 'P' Proposed
kemPk = decodeLarge(d)
} else if (tag === 0x41) { // 'A' Accepted
kemCt = decodeLarge(d)
kemAcceptPk = decodeLarge(d)
}
}
}
return {version, pk1Raw, pk2Raw, kemPk, kemCt, kemAcceptPk}
}
// Encode E2ERatchetParams for sending to peer
function encodeE2EParams(
version: number,
pk1Raw: Uint8Array, pk2Raw: Uint8Array,
kemPk: Uint8Array | null, // for proposed
kemCt: Uint8Array | null, // for accepted
kemAcceptPk: Uint8Array | null, // public key in accepted
): Uint8Array {
const vBytes = new Uint8Array(2)
vBytes[0] = (version >> 8) & 0xff
vBytes[1] = version & 0xff
const parts = [vBytes, encodeBytes(encodePubKeyX448(pk1Raw)), encodeBytes(encodePubKeyX448(pk2Raw))]
if (version >= 3) {
if (kemCt && kemAcceptPk) {
// Just Accepted
parts.push(new Uint8Array([0x31, 0x41])) // Just + 'A'
parts.push(new Uint8Array([(kemCt.length >> 8) & 0xff, kemCt.length & 0xff]))
parts.push(kemCt)
parts.push(new Uint8Array([(kemAcceptPk.length >> 8) & 0xff, kemAcceptPk.length & 0xff]))
parts.push(kemAcceptPk)
} else if (kemPk) {
// Just Proposed
parts.push(new Uint8Array([0x31, 0x50])) // Just + 'P'
parts.push(new Uint8Array([(kemPk.length >> 8) & 0xff, kemPk.length & 0xff]))
parts.push(kemPk)
} else {
// Nothing
parts.push(new Uint8Array([0x30]))
}
}
return concatBytes(...parts)
}
// -- Init handlers
function handleInitRcv(version: number, pqSupport: boolean): string {
const kp1 = generateX448KeyPair()
const kp2 = generateX448KeyPair()
let kemKeyPair: KEMKeyPair | null = null
let kemPk: Uint8Array | null = null
if (pqSupport) {
kemKeyPair = sntrup761Keypair()
kemPk = kemKeyPair.publicKey
}
rcvInitState = {privKey1: kp1.privateKey, privKey2: kp2.privateKey, kemKeyPair, pqSupport}
const params = encodeE2EParams(version, kp1.publicKey, kp2.publicKey, kemPk, null, null)
return "ok: " + toHex(params)
}
function handleComplete(peerParamsHex: string): string {
if (!rcvInitState) return "error: not in RCV init state"
const {privKey1, privKey2, kemKeyPair, pqSupport} = rcvInitState
const peerParams = parseE2EParams(fromHex(peerParamsHex))
// Build kemAccepted for X3DH if peer accepted our KEM proposal
let kemAccepted: RatchetKEMAccepted | null = null
if (peerParams.kemCt && peerParams.kemAcceptPk && kemKeyPair) {
const ss = sntrup761Dec(peerParams.kemCt, kemKeyPair.secretKey)
kemAccepted = {rcPQRr: peerParams.kemAcceptPk, rcPQRss: ss, rcPQRct: peerParams.kemCt}
}
// X3DH (receiver side)
const initParams = pqX3dhRcv(privKey1, privKey2, peerParams.pk1Raw, peerParams.pk2Raw, kemAccepted)
// Init receiving ratchet
const vs: RatchetVersions = {current: peerParams.version, maxSupported: peerParams.version}
ratchet = initRcvRatchet(vs, privKey2, initParams, kemKeyPair, pqSupport)
skippedKeys = new Map()
rcvInitState = null
return "ok"
}
function handleInitSnd(version: number, kemMode: string, peerParamsHex: string): string {
const peerParams = parseE2EParams(fromHex(peerParamsHex))
const kp1 = generateX448KeyPair()
const kp2 = generateX448KeyPair()
const kp3 = generateX448KeyPair() // fresh DH key for ratchet
// KEM handling
let kemAccepted: RatchetKEMAccepted | null = null
let ownKemKp: KEMKeyPair | null = null
let outKemPk: Uint8Array | null = null
let outKemCt: Uint8Array | null = null
let outKemAcceptPk: Uint8Array | null = null
if (kemMode === "accept" && peerParams.kemPk) {
// Accept peer's KEM proposal
const encResult = sntrup761Enc(peerParams.kemPk)
ownKemKp = sntrup761Keypair()
kemAccepted = {rcPQRr: peerParams.kemPk, rcPQRss: encResult.sharedSecret, rcPQRct: encResult.ciphertext}
outKemCt = encResult.ciphertext
outKemAcceptPk = ownKemKp.publicKey
} else if (kemMode === "propose") {
ownKemKp = sntrup761Keypair()
outKemPk = ownKemKp.publicKey
}
// X3DH (sender side)
const initParams = pqX3dhSnd(kp1.privateKey, kp2.privateKey, peerParams.pk1Raw, peerParams.pk2Raw, kemAccepted)
// Init sending ratchet
const vs: RatchetVersions = {current: version, maxSupported: version}
ratchet = initSndRatchet(vs, peerParams.pk2Raw, kp3.privateKey, initParams, ownKemKp)
skippedKeys = new Map()
const params = encodeE2EParams(version, kp1.publicKey, kp2.publicKey, outKemPk, outKemCt, outKemAcceptPk)
return "ok: " + toHex(params)
}
// -- Encrypt/decrypt handlers
function handleEncrypt(kemAssert: boolean | null, _pqPref: boolean | null, plaintext: string): string {
if (!ratchet) return "error: not initialized"
try {
const result = rcEncrypt(ratchet, new TextEncoder().encode(plaintext), PADDED_MSG_LEN)
ratchet = result.state
if (kemAssert === true && !ratchet.rcSndKEM) return "error: expected hasSndKEM"
if (kemAssert === false && ratchet.rcSndKEM) return "error: expected noSndKEM"
return "ok: " + toHex(result.ciphertext)
} catch (e: any) {
return "error: " + e.message
}
}
function handleDecrypt(kemAssert: boolean | null, hexCt: string, expectedPlaintext: string | null): string {
if (!ratchet) return "error: not initialized"
try {
const ct = fromHex(hexCt)
const result = rcDecrypt(ratchet, skippedKeys, ct)
ratchet = result.state
skippedKeys = result.skippedKeys
const plaintext = new TextDecoder().decode(result.plaintext)
if (kemAssert === true && !ratchet.rcRcvKEM) return "error: expected hasRcvKEM"
if (kemAssert === false && ratchet.rcRcvKEM) return "error: expected noRcvKEM"
if (expectedPlaintext !== null && plaintext !== expectedPlaintext)
return "error: expected '" + expectedPlaintext + "', got '" + plaintext + "'"
return "ok: " + plaintext
} catch (e: any) {
return "error: " + e.message
}
}
// -- Command parser
function parseLine(line: string): string {
// Init commands
if (line.startsWith("INIT_RCV ")) {
const parts = line.split(" ")
return handleInitRcv(parseInt(parts[1]), parts[2] === "1")
}
if (line.startsWith("COMPLETE ")) {
return handleComplete(line.substring(9).trim())
}
if (line.startsWith("INIT_SND ")) {
const parts = line.split(" ")
return handleInitSnd(parseInt(parts[1]), parts[2], parts[3])
}
// Query commands
if (line === "SNDKEM") {
if (!ratchet) return "error: not initialized"
return "ok: " + (ratchet.rcSndKEM ? "1" : "0")
}
if (line === "RCVKEM") {
if (!ratchet) return "error: not initialized"
return "ok: " + (ratchet.rcRcvKEM ? "1" : "0")
}
// Encrypt operators: \#> !#> \#>! !#>! !#>\ \#>\
const encMatch = line.match(/^([!\\])#(>[!\\]?)\s+(.+)$/)
if (encMatch) {
const [, kemChar, arrow, msg] = encMatch
const kemAssert = kemChar === "!" ? true : false
let pqPref: boolean | null = null
if (arrow === ">!") pqPref = true
else if (arrow === ">\\") pqPref = false
return handleEncrypt(kemAssert, pqPref, msg)
}
// Decrypt operators: <#\ <#!
const decMatch = line.match(/^<#([!\\])\s+(\S+)\s+(.+)$/)
if (decMatch) {
const [, kemChar, hexCt, expected] = decMatch
const kemAssert = kemChar === "!" ? true : false
return handleDecrypt(kemAssert, hexCt, expected)
}
// Plain encrypt (no assertion)
if (line.startsWith("E ")) {
return handleEncrypt(null, null, line.substring(2))
}
// Plain decrypt (no assertion, no expected)
if (line.startsWith("D ")) {
return handleDecrypt(null, line.substring(2), null)
}
return "error: unknown command: " + line
}
// -- Main
async function main() {
await initSntrup761()
const rl = createInterface({input: process.stdin, terminal: false})
for await (const line of rl) {
const trimmed = line.trim()
if (!trimmed) continue
const response = parseLine(trimmed)
process.stdout.write(response + "\n")
}
}
main().catch(e => {
process.stderr.write("FATAL: " + e.message + "\n")
process.exit(1)
})
+15
View File
@@ -0,0 +1,15 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ES2022",
"moduleResolution": "node",
"lib": ["ES2022"],
"outDir": "dist-test",
"rootDir": "tests",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"sourceMap": true
},
"include": ["tests/**/*.ts"]
}
+24 -18
View File
@@ -73,15 +73,19 @@ runMessageTests ::
Bool ->
Spec
runMessageTests initRatchets_ agreeRatchetKEMs = do
it "should encrypt and decrypt messages" $ run $ testEncryptDecrypt agreeRatchetKEMs
it "should encrypt and decrypt skipped messages" $ run $ testSkippedMessages agreeRatchetKEMs
it "should encrypt and decrypt many messages" $ run $ testManyMessages agreeRatchetKEMs
it "should allow skipped after ratchet advance" $ run $ testSkippedAfterRatchetAdvance agreeRatchetKEMs
it "should encrypt and decrypt messages" $ run testEncryptDecrypt
it "should encrypt and decrypt skipped messages" $ run testSkippedMessages
it "should encrypt and decrypt many messages" $ run testManyMessages
it "should allow skipped after ratchet advance" $ run testSkippedAfterRatchetAdvance
where
run :: (forall a. (AlgorithmI a, DhAlgorithm a) => TestRatchets a) -> IO ()
run test = do
withRatchets_ @X25519 initRatchets_ test
withRatchets_ @X448 initRatchets_ test
withRatchets_ @X25519 initRatchets_ (withKEM test)
withRatchets_ @X448 initRatchets_ (withKEM test)
withKEM :: (AlgorithmI a, DhAlgorithm a) => TestRatchets a -> TestRatchets a
withKEM test alice bob encrypt decrypt (#>) = do
when agreeRatchetKEMs $ initRatchetKEM bob alice >> initRatchetKEM alice bob
test alice bob encrypt decrypt (#>)
testAlgs :: (forall a. (AlgorithmI a, DhAlgorithm a) => C.SAlgorithm a -> IO ()) -> IO ()
testAlgs test = test C.SX25519 >> test C.SX448
@@ -146,6 +150,12 @@ type TestRatchets a =
EncryptDecryptSpec a ->
IO ()
-- Peer-polymorphic types for cross-language testing
type EncryptP p = p -> ByteString -> IO (Either CryptoError ByteString)
type DecryptP p = p -> ByteString -> IO (Either CryptoError (Either CryptoError ByteString))
type EncryptDecryptSpecP p = (p, ByteString) -> p -> Expectation
type TestRatchetsP p = p -> p -> EncryptP p -> DecryptP p -> EncryptDecryptSpecP p -> IO ()
deriving instance Eq (Ratchet a)
deriving instance Eq (SndRatchet a)
@@ -170,9 +180,8 @@ deriving instance Eq (MsgHeader a)
initRatchetKEM :: (AlgorithmI a, DhAlgorithm a) => TVar (TVar ChaChaDRG, Ratchet a, SkippedMsgKeys) -> TVar (TVar ChaChaDRG, Ratchet a, SkippedMsgKeys) -> IO ()
initRatchetKEM s r = encryptDecrypt (Just $ PQEncOn) (const ()) (const ()) (s, "initialising ratchet") r
testEncryptDecrypt :: (AlgorithmI a, DhAlgorithm a) => Bool -> TestRatchets a
testEncryptDecrypt agreeRatchetKEMs alice bob encrypt decrypt (#>) = do
when agreeRatchetKEMs $ initRatchetKEM bob alice >> initRatchetKEM alice bob
testEncryptDecrypt :: TestRatchetsP p
testEncryptDecrypt alice bob encrypt decrypt (#>) = do
(bob, "hello alice") #> alice
(alice, "hello bob") #> bob
Right b1 <- encrypt bob "how are you, alice?"
@@ -191,9 +200,8 @@ testEncryptDecrypt agreeRatchetKEMs alice bob encrypt decrypt (#>) = do
(alice, "I'm here too, same") #> bob
pure ()
testSkippedMessages :: (AlgorithmI a, DhAlgorithm a) => Bool -> TestRatchets a
testSkippedMessages agreeRatchetKEMs alice bob encrypt decrypt _ = do
when agreeRatchetKEMs $ initRatchetKEM bob alice >> initRatchetKEM alice bob
testSkippedMessages :: TestRatchetsP p
testSkippedMessages alice bob encrypt decrypt _ = do
Right msg1 <- encrypt bob "hello alice"
Right msg2 <- encrypt bob "hello there again"
Right msg3 <- encrypt bob "are you there?"
@@ -203,9 +211,8 @@ testSkippedMessages agreeRatchetKEMs alice bob encrypt decrypt _ = do
Decrypted "hello alice" <- decrypt alice msg1
pure ()
testManyMessages :: (AlgorithmI a, DhAlgorithm a) => Bool -> TestRatchets a
testManyMessages agreeRatchetKEMs alice bob _ _ (#>) = do
when agreeRatchetKEMs $ initRatchetKEM bob alice >> initRatchetKEM alice bob
testManyMessages :: TestRatchetsP p
testManyMessages alice bob _ _ (#>) = do
(bob, "b1") #> alice
(bob, "b2") #> alice
(bob, "b3") #> alice
@@ -222,9 +229,8 @@ testManyMessages agreeRatchetKEMs alice bob _ _ (#>) = do
(bob, "b15") #> alice
(bob, "b16") #> alice
testSkippedAfterRatchetAdvance :: (AlgorithmI a, DhAlgorithm a) => Bool -> TestRatchets a
testSkippedAfterRatchetAdvance agreeRatchetKEMs alice bob encrypt decrypt (#>) = do
when agreeRatchetKEMs $ initRatchetKEM bob alice >> initRatchetKEM alice bob
testSkippedAfterRatchetAdvance :: TestRatchetsP p
testSkippedAfterRatchetAdvance alice bob encrypt decrypt (#>) = do
(bob, "b1") #> alice
Right b2 <- encrypt bob "b2"
Right b3 <- encrypt bob "b3"
+993 -6
View File
File diff suppressed because it is too large Load Diff