mirror of
https://github.com/simplex-chat/simplexmq.git
synced 2026-07-10 01:22:33 +00:00
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:
@@ -1,3 +1,4 @@
|
||||
node_modules/
|
||||
dist/
|
||||
dist-test/
|
||||
package-lock.json
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
@@ -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];
|
||||
}
|
||||
@@ -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
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user