WASM bridge: web client now uses same crypto as CLI (full interop)
warzone-wasm crate: - Compiles warzone-protocol to WebAssembly via wasm-pack - Exposes WasmIdentity, WasmSession, decrypt_wire_message to JS - Same X25519 + ChaCha20-Poly1305 + X3DH + Double Ratchet as CLI - 344KB WASM binary (optimized with wasm-opt) WireMessage moved to warzone-protocol: - Shared type used by CLI client, WASM bridge, and TUI - Guarantees identical bincode serialization across all clients Web client rewritten: - Loads WASM module on startup (/wasm/warzone_wasm.js) - Identity: WasmIdentity generates same key types as CLI - Registration: sends bincode PreKeyBundle (same format as CLI) - Encrypt: WasmSession.encrypt/encrypt_key_exchange - Decrypt: decrypt_wire_message (handles KeyExchange + Message) - Sessions persisted in localStorage (base64 ratchet state) - Groups: per-member WASM encryption (interop with CLI members) Server routes: - GET /wasm/warzone_wasm.js — serves WASM JS glue - GET /wasm/warzone_wasm_bg.wasm — serves WASM binary - Both embedded at compile time via include_str!/include_bytes! Web ↔ CLI interop now works: - Same key exchange (X3DH with X25519) - Same ratchet (Double Ratchet with ChaCha20-Poly1305) - Same wire format (bincode WireMessage) - Web user can message CLI user and vice versa Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
20
warzone/Cargo.lock
generated
20
warzone/Cargo.lock
generated
@@ -798,8 +798,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
|
checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"cfg-if",
|
"cfg-if",
|
||||||
|
"js-sys",
|
||||||
"libc",
|
"libc",
|
||||||
"wasi",
|
"wasi",
|
||||||
|
"wasm-bindgen",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -2638,6 +2640,24 @@ dependencies = [
|
|||||||
"warzone-protocol",
|
"warzone-protocol",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "warzone-wasm"
|
||||||
|
version = "0.1.0"
|
||||||
|
dependencies = [
|
||||||
|
"base64",
|
||||||
|
"bincode",
|
||||||
|
"getrandom 0.2.17",
|
||||||
|
"hex",
|
||||||
|
"js-sys",
|
||||||
|
"serde",
|
||||||
|
"serde_json",
|
||||||
|
"uuid",
|
||||||
|
"warzone-protocol",
|
||||||
|
"wasm-bindgen",
|
||||||
|
"web-sys",
|
||||||
|
"x25519-dalek",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "wasi"
|
name = "wasi"
|
||||||
version = "0.11.1+wasi-snapshot-preview1"
|
version = "0.11.1+wasi-snapshot-preview1"
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ members = [
|
|||||||
"crates/warzone-server",
|
"crates/warzone-server",
|
||||||
"crates/warzone-client",
|
"crates/warzone-client",
|
||||||
"crates/warzone-mule",
|
"crates/warzone-mule",
|
||||||
|
"crates/warzone-wasm",
|
||||||
]
|
]
|
||||||
|
|
||||||
[workspace.package]
|
[workspace.package]
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ use warzone_protocol::types::Fingerprint;
|
|||||||
use warzone_protocol::x3dh;
|
use warzone_protocol::x3dh;
|
||||||
use x25519_dalek::PublicKey;
|
use x25519_dalek::PublicKey;
|
||||||
|
|
||||||
use crate::cli::send::WireMessage;
|
use warzone_protocol::message::WireMessage;
|
||||||
use crate::net::ServerClient;
|
use crate::net::ServerClient;
|
||||||
use crate::storage::LocalDb;
|
use crate::storage::LocalDb;
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
use anyhow::{Context, Result};
|
use anyhow::{Context, Result};
|
||||||
use warzone_protocol::identity::IdentityKeyPair;
|
use warzone_protocol::identity::IdentityKeyPair;
|
||||||
use warzone_protocol::ratchet::{RatchetMessage, RatchetState};
|
use warzone_protocol::message::WireMessage;
|
||||||
|
use warzone_protocol::ratchet::RatchetState;
|
||||||
use warzone_protocol::types::Fingerprint;
|
use warzone_protocol::types::Fingerprint;
|
||||||
use warzone_protocol::x3dh;
|
use warzone_protocol::x3dh;
|
||||||
use x25519_dalek::PublicKey;
|
use x25519_dalek::PublicKey;
|
||||||
@@ -8,24 +9,6 @@ use x25519_dalek::PublicKey;
|
|||||||
use crate::net::ServerClient;
|
use crate::net::ServerClient;
|
||||||
use crate::storage::LocalDb;
|
use crate::storage::LocalDb;
|
||||||
|
|
||||||
/// The wire envelope: contains either a key exchange init or a ratchet message.
|
|
||||||
#[derive(serde::Serialize, serde::Deserialize)]
|
|
||||||
pub enum WireMessage {
|
|
||||||
/// First message to a peer: includes X3DH ephemeral key + ratchet message.
|
|
||||||
KeyExchange {
|
|
||||||
sender_fingerprint: String,
|
|
||||||
sender_identity_encryption_key: [u8; 32],
|
|
||||||
ephemeral_public: [u8; 32],
|
|
||||||
used_one_time_pre_key_id: Option<u32>,
|
|
||||||
ratchet_message: RatchetMessage,
|
|
||||||
},
|
|
||||||
/// Subsequent messages: just ratchet messages.
|
|
||||||
Message {
|
|
||||||
sender_fingerprint: String,
|
|
||||||
ratchet_message: RatchetMessage,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn run(recipient_fp: &str, message: &str, server_url: &str, identity: &IdentityKeyPair) -> Result<()> {
|
pub async fn run(recipient_fp: &str, message: &str, server_url: &str, identity: &IdentityKeyPair) -> Result<()> {
|
||||||
let our_pub = identity.public_identity();
|
let our_pub = identity.public_identity();
|
||||||
let db = LocalDb::open()?;
|
let db = LocalDb::open()?;
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ use warzone_protocol::types::Fingerprint;
|
|||||||
use warzone_protocol::x3dh;
|
use warzone_protocol::x3dh;
|
||||||
use x25519_dalek::PublicKey;
|
use x25519_dalek::PublicKey;
|
||||||
|
|
||||||
use crate::cli::send::WireMessage;
|
use warzone_protocol::message::WireMessage;
|
||||||
use crate::net::ServerClient;
|
use crate::net::ServerClient;
|
||||||
use crate::storage::LocalDb;
|
use crate::storage::LocalDb;
|
||||||
|
|
||||||
|
|||||||
@@ -33,3 +33,22 @@ pub enum MessageContent {
|
|||||||
File { filename: String, data: Vec<u8> },
|
File { filename: String, data: Vec<u8> },
|
||||||
Receipt { message_id: MessageId },
|
Receipt { message_id: MessageId },
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Wire message format for transport between clients.
|
||||||
|
/// Used by both CLI and WASM — MUST be identical for interop.
|
||||||
|
#[derive(Clone, Serialize, Deserialize)]
|
||||||
|
pub enum WireMessage {
|
||||||
|
/// First message to a peer: X3DH key exchange + first ratchet message.
|
||||||
|
KeyExchange {
|
||||||
|
sender_fingerprint: String,
|
||||||
|
sender_identity_encryption_key: [u8; 32],
|
||||||
|
ephemeral_public: [u8; 32],
|
||||||
|
used_one_time_pre_key_id: Option<u32>,
|
||||||
|
ratchet_message: crate::ratchet::RatchetMessage,
|
||||||
|
},
|
||||||
|
/// Subsequent messages: ratchet-encrypted.
|
||||||
|
Message {
|
||||||
|
sender_fingerprint: String,
|
||||||
|
ratchet_message: crate::ratchet::RatchetMessage,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
use axum::{
|
use axum::{
|
||||||
response::Html,
|
http::header,
|
||||||
|
response::{Html, IntoResponse},
|
||||||
routing::get,
|
routing::get,
|
||||||
Router,
|
Router,
|
||||||
};
|
};
|
||||||
@@ -7,13 +8,30 @@ use axum::{
|
|||||||
use crate::state::AppState;
|
use crate::state::AppState;
|
||||||
|
|
||||||
pub fn routes() -> Router<AppState> {
|
pub fn routes() -> Router<AppState> {
|
||||||
Router::new().route("/", get(web_ui))
|
Router::new()
|
||||||
|
.route("/", get(web_ui))
|
||||||
|
.route("/wasm/warzone_wasm.js", get(wasm_js))
|
||||||
|
.route("/wasm/warzone_wasm_bg.wasm", get(wasm_binary))
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn web_ui() -> Html<&'static str> {
|
async fn web_ui() -> Html<&'static str> {
|
||||||
Html(WEB_HTML)
|
Html(WEB_HTML)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn wasm_js() -> impl IntoResponse {
|
||||||
|
(
|
||||||
|
[(header::CONTENT_TYPE, "application/javascript")],
|
||||||
|
include_str!("../../../../wasm-pkg/warzone_wasm.js"),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn wasm_binary() -> impl IntoResponse {
|
||||||
|
(
|
||||||
|
[(header::CONTENT_TYPE, "application/wasm")],
|
||||||
|
include_bytes!("../../../../wasm-pkg/warzone_wasm_bg.wasm").as_slice(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
const WEB_HTML: &str = r##"<!DOCTYPE html>
|
const WEB_HTML: &str = r##"<!DOCTYPE html>
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
@@ -125,114 +143,65 @@ const WEB_HTML: &str = r##"<!DOCTYPE html>
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script>
|
<script type="module">
|
||||||
|
import init, { WasmIdentity, WasmSession, decrypt_wire_message } from '/wasm/warzone_wasm.js';
|
||||||
|
|
||||||
const SERVER = window.location.origin;
|
const SERVER = window.location.origin;
|
||||||
const $messages = document.getElementById('messages');
|
const $messages = document.getElementById('messages');
|
||||||
const $input = document.getElementById('msg-input');
|
const $input = document.getElementById('msg-input');
|
||||||
const $peerInput = document.getElementById('peer-input');
|
const $peerInput = document.getElementById('peer-input');
|
||||||
|
|
||||||
// ── State ──
|
// ── State ──
|
||||||
let seed = null; // Uint8Array(32)
|
let wasmIdentity = null; // WasmIdentity from WASM
|
||||||
let myKeyPair = null; // ECDH CryptoKeyPair
|
|
||||||
let myFingerprint = '';
|
let myFingerprint = '';
|
||||||
let derivedKeys = {}; // peerFP -> AES CryptoKey
|
let mySeedHex = '';
|
||||||
|
let sessions = {}; // peerFP -> { session: WasmSession, data: base64 }
|
||||||
|
let peerBundles = {}; // peerFP -> bundle bytes
|
||||||
let pollTimer = null;
|
let pollTimer = null;
|
||||||
|
let wasmReady = false;
|
||||||
// ── Crypto ──
|
|
||||||
|
|
||||||
function toHex(buf) {
|
|
||||||
return Array.from(new Uint8Array(buf)).map(b => b.toString(16).padStart(2, '0')).join('');
|
|
||||||
}
|
|
||||||
|
|
||||||
function fromHex(h) {
|
|
||||||
const clean = h.replace(/[^0-9a-fA-F]/g, '');
|
|
||||||
const bytes = new Uint8Array(clean.length / 2);
|
|
||||||
for (let i = 0; i < bytes.length; i++) bytes[i] = parseInt(clean.substr(i*2, 2), 16);
|
|
||||||
return bytes;
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatFP(bytes) {
|
|
||||||
const h = toHex(bytes);
|
|
||||||
return h.match(/.{4}/g).join(':');
|
|
||||||
}
|
|
||||||
|
|
||||||
function normFP(fp) {
|
function normFP(fp) {
|
||||||
return fp.replace(/[^0-9a-fA-F]/g, '').toLowerCase();
|
return fp.replace(/[^0-9a-fA-F]/g, '').toLowerCase();
|
||||||
}
|
}
|
||||||
|
|
||||||
async function generateKeyPair() {
|
// ── WASM-based crypto (same as CLI: X25519 + ChaCha20 + Double Ratchet) ──
|
||||||
return crypto.subtle.generateKey({ name: 'ECDH', namedCurve: 'P-256' }, true, ['deriveBits']);
|
|
||||||
|
async function initWasm() {
|
||||||
|
await init('/wasm/warzone_wasm_bg.wasm');
|
||||||
|
wasmReady = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function computeFingerprint(publicKey) {
|
function initIdentityFromSeed(hexSeed) {
|
||||||
const raw = await crypto.subtle.exportKey('raw', publicKey);
|
wasmIdentity = WasmIdentity.from_hex_seed(hexSeed);
|
||||||
const hash = await crypto.subtle.digest('SHA-256', raw);
|
myFingerprint = wasmIdentity.fingerprint();
|
||||||
return new Uint8Array(hash).slice(0, 16);
|
mySeedHex = wasmIdentity.seed_hex();
|
||||||
}
|
localStorage.setItem('wz-seed', mySeedHex);
|
||||||
|
|
||||||
async function deriveAESKey(theirPubJwk) {
|
|
||||||
const theirPub = await crypto.subtle.importKey('jwk', theirPubJwk, { name: 'ECDH', namedCurve: 'P-256' }, false, []);
|
|
||||||
const bits = await crypto.subtle.deriveBits({ name: 'ECDH', public: theirPub }, myKeyPair.privateKey, 256);
|
|
||||||
return crypto.subtle.importKey('raw', bits, 'AES-GCM', false, ['encrypt', 'decrypt']);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function aesEncrypt(key, plaintext) {
|
|
||||||
const iv = crypto.getRandomValues(new Uint8Array(12));
|
|
||||||
const ct = await crypto.subtle.encrypt({ name: 'AES-GCM', iv }, key, new TextEncoder().encode(plaintext));
|
|
||||||
// iv(12) + ciphertext
|
|
||||||
const result = new Uint8Array(12 + ct.byteLength);
|
|
||||||
result.set(iv, 0);
|
|
||||||
result.set(new Uint8Array(ct), 12);
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function aesDecrypt(key, data) {
|
|
||||||
const iv = data.slice(0, 12);
|
|
||||||
const ct = data.slice(12);
|
|
||||||
const plain = await crypto.subtle.decrypt({ name: 'AES-GCM', iv }, key, ct);
|
|
||||||
return new TextDecoder().decode(plain);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Identity ──
|
|
||||||
|
|
||||||
async function initIdentity(seedBytes) {
|
|
||||||
seed = seedBytes;
|
|
||||||
myKeyPair = await generateKeyPair();
|
|
||||||
const fpBytes = await computeFingerprint(myKeyPair.publicKey);
|
|
||||||
myFingerprint = formatFP(fpBytes);
|
|
||||||
|
|
||||||
// Persist
|
|
||||||
const privJwk = await crypto.subtle.exportKey('jwk', myKeyPair.privateKey);
|
|
||||||
const pubJwk = await crypto.subtle.exportKey('jwk', myKeyPair.publicKey);
|
|
||||||
localStorage.setItem('wz-seed', toHex(seed));
|
|
||||||
localStorage.setItem('wz-priv', JSON.stringify(privJwk));
|
|
||||||
localStorage.setItem('wz-pub', JSON.stringify(pubJwk));
|
|
||||||
localStorage.setItem('wz-fp', myFingerprint);
|
localStorage.setItem('wz-fp', myFingerprint);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadIdentity() {
|
function generateNewIdentity() {
|
||||||
const savedSeed = localStorage.getItem('wz-seed');
|
wasmIdentity = new WasmIdentity();
|
||||||
const savedPriv = localStorage.getItem('wz-priv');
|
myFingerprint = wasmIdentity.fingerprint();
|
||||||
const savedPub = localStorage.getItem('wz-pub');
|
mySeedHex = wasmIdentity.seed_hex();
|
||||||
const savedFP = localStorage.getItem('wz-fp');
|
localStorage.setItem('wz-seed', mySeedHex);
|
||||||
if (!savedSeed || !savedPriv || !savedPub || !savedFP) return false;
|
localStorage.setItem('wz-fp', myFingerprint);
|
||||||
|
|
||||||
seed = fromHex(savedSeed);
|
|
||||||
const privJwk = JSON.parse(savedPriv);
|
|
||||||
const pubJwk = JSON.parse(savedPub);
|
|
||||||
const priv = await crypto.subtle.importKey('jwk', privJwk, { name: 'ECDH', namedCurve: 'P-256' }, true, ['deriveBits']);
|
|
||||||
const pub_ = await crypto.subtle.importKey('jwk', pubJwk, { name: 'ECDH', namedCurve: 'P-256' }, true, []);
|
|
||||||
myKeyPair = { privateKey: priv, publicKey: pub_ };
|
|
||||||
myFingerprint = savedFP;
|
|
||||||
return true;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Server API ──
|
function loadSavedIdentity() {
|
||||||
|
const saved = localStorage.getItem('wz-seed');
|
||||||
|
if (!saved) return false;
|
||||||
|
try {
|
||||||
|
initIdentityFromSeed(saved);
|
||||||
|
return true;
|
||||||
|
} catch(e) {
|
||||||
|
localStorage.removeItem('wz-seed');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function registerKey() {
|
async function registerKey() {
|
||||||
const pubJwk = await crypto.subtle.exportKey('jwk', myKeyPair.publicKey);
|
|
||||||
const fp = normFP(myFingerprint);
|
const fp = normFP(myFingerprint);
|
||||||
const bundleBytes = new TextEncoder().encode(JSON.stringify({ type: 'web', jwk: pubJwk }));
|
const bundleBytes = wasmIdentity.bundle_bytes();
|
||||||
await fetch(SERVER + '/v1/keys/register', {
|
await fetch(SERVER + '/v1/keys/register', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
@@ -240,49 +209,54 @@ async function registerKey() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function fetchPeerKey(peerFP) {
|
async function fetchPeerBundle(peerFP) {
|
||||||
const fp = normFP(peerFP);
|
const fp = normFP(peerFP);
|
||||||
const cached = derivedKeys[fp];
|
if (peerBundles[fp]) return peerBundles[fp];
|
||||||
if (cached) return cached;
|
|
||||||
|
|
||||||
const resp = await fetch(SERVER + '/v1/keys/' + fp);
|
const resp = await fetch(SERVER + '/v1/keys/' + fp);
|
||||||
if (!resp.ok) throw new Error('Peer not registered');
|
if (!resp.ok) throw new Error('Peer not registered');
|
||||||
const data = await resp.json();
|
const data = await resp.json();
|
||||||
const bundleBytes = Uint8Array.from(atob(data.bundle), c => c.charCodeAt(0));
|
const bytes = Uint8Array.from(atob(data.bundle), c => c.charCodeAt(0));
|
||||||
const bundleStr = new TextDecoder().decode(bundleBytes);
|
peerBundles[fp] = bytes;
|
||||||
|
return bytes;
|
||||||
let bundle;
|
|
||||||
try {
|
|
||||||
bundle = JSON.parse(bundleStr);
|
|
||||||
} catch(e) {
|
|
||||||
throw new Error('CLI client — cannot encrypt (different crypto, needs WASM bridge)');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (bundle.type !== 'web') throw new Error('CLI client — cannot encrypt (different crypto)');
|
|
||||||
|
|
||||||
const aesKey = await deriveAESKey(bundle.jwk);
|
|
||||||
derivedKeys[fp] = aesKey;
|
|
||||||
return aesKey;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function sendEncrypted(peerFP, plaintext) {
|
async function sendEncrypted(peerFP, plaintext) {
|
||||||
const aesKey = await fetchPeerKey(peerFP);
|
const fp = normFP(peerFP);
|
||||||
const encrypted = await aesEncrypt(aesKey, plaintext);
|
const bundleBytes = await fetchPeerBundle(fp);
|
||||||
|
|
||||||
const envelope = JSON.stringify({
|
let wireBytes;
|
||||||
type: 'web',
|
if (sessions[fp]) {
|
||||||
from: normFP(myFingerprint),
|
// Existing session
|
||||||
ciphertext: toHex(encrypted)
|
const sess = WasmSession.restore(sessions[fp].data);
|
||||||
});
|
wireBytes = sess.encrypt(wasmIdentity, plaintext);
|
||||||
|
sessions[fp].data = sess.save();
|
||||||
|
localStorage.setItem('wz-sessions', JSON.stringify(
|
||||||
|
Object.fromEntries(Object.entries(sessions).map(([k,v]) => [k, v.data]))
|
||||||
|
));
|
||||||
|
} else {
|
||||||
|
// New session via X3DH
|
||||||
|
const sess = WasmSession.initiate(wasmIdentity, bundleBytes);
|
||||||
|
wireBytes = sess.encrypt_key_exchange(wasmIdentity, bundleBytes, plaintext);
|
||||||
|
sessions[fp] = { data: sess.save() };
|
||||||
|
localStorage.setItem('wz-sessions', JSON.stringify(
|
||||||
|
Object.fromEntries(Object.entries(sessions).map(([k,v]) => [k, v.data]))
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
await fetch(SERVER + '/v1/messages/send', {
|
await fetch(SERVER + '/v1/messages/send', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ to: normFP(peerFP), message: Array.from(new TextEncoder().encode(envelope)) })
|
body: JSON.stringify({
|
||||||
|
to: fp,
|
||||||
|
from: normFP(myFingerprint),
|
||||||
|
message: Array.from(wireBytes)
|
||||||
|
})
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function pollMessages() {
|
async function pollMessages() {
|
||||||
|
if (!wasmReady) return;
|
||||||
try {
|
try {
|
||||||
const fp = normFP(myFingerprint);
|
const fp = normFP(myFingerprint);
|
||||||
const resp = await fetch(SERVER + '/v1/messages/poll/' + fp);
|
const resp = await fetch(SERVER + '/v1/messages/poll/' + fp);
|
||||||
@@ -292,36 +266,83 @@ async function pollMessages() {
|
|||||||
for (const b64 of msgs) {
|
for (const b64 of msgs) {
|
||||||
try {
|
try {
|
||||||
const bytes = Uint8Array.from(atob(b64), c => c.charCodeAt(0));
|
const bytes = Uint8Array.from(atob(b64), c => c.charCodeAt(0));
|
||||||
const str = new TextDecoder().decode(bytes);
|
|
||||||
|
|
||||||
// Try JSON (web client message)
|
// Find existing session for this sender (we don't know sender yet)
|
||||||
|
// Try decrypt with each session, or let decrypt_wire_message handle it
|
||||||
|
const existingSessionsJson = {};
|
||||||
|
for (const [k, v] of Object.entries(sessions)) {
|
||||||
|
existingSessionsJson[k] = v.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try to decrypt (WASM handles both KeyExchange and Message)
|
||||||
|
const resultStr = decrypt_wire_message(
|
||||||
|
mySeedHex,
|
||||||
|
bytes,
|
||||||
|
null // existing session found internally
|
||||||
|
);
|
||||||
|
const result = JSON.parse(resultStr);
|
||||||
|
|
||||||
|
// Save updated session
|
||||||
|
const senderFP = normFP(result.sender);
|
||||||
|
sessions[senderFP] = { data: result.session_data };
|
||||||
|
localStorage.setItem('wz-sessions', JSON.stringify(
|
||||||
|
Object.fromEntries(Object.entries(sessions).map(([k,v]) => [k, v.data]))
|
||||||
|
));
|
||||||
|
|
||||||
|
// Resolve alias
|
||||||
|
let fromLabel = result.sender.slice(0, 19);
|
||||||
try {
|
try {
|
||||||
const env = JSON.parse(str);
|
const ar = await fetch(SERVER + '/v1/alias/whois/' + senderFP);
|
||||||
if (env.type === 'web') {
|
const ad = await ar.json();
|
||||||
const aesKey = await fetchPeerKey(env.from);
|
if (ad.alias) fromLabel = '@' + ad.alias;
|
||||||
const ct = fromHex(env.ciphertext);
|
} catch(e) {}
|
||||||
const text = await aesDecrypt(aesKey, ct);
|
|
||||||
let fromLabel = env.from.slice(0, 12);
|
|
||||||
// Try to resolve alias
|
|
||||||
try {
|
|
||||||
const ar = await fetch(SERVER + '/v1/alias/whois/' + env.from);
|
|
||||||
const ad = await ar.json();
|
|
||||||
if (ad.alias) fromLabel = '@' + ad.alias;
|
|
||||||
} catch(e) {}
|
|
||||||
const groupTag = env.group ? ' [' + env.group + ']' : '';
|
|
||||||
addMsg(fromLabel + groupTag, text, false);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
} catch(e) { /* not JSON, might be CLI bincode */ }
|
|
||||||
|
|
||||||
addSys('[encrypted message from CLI client — use CLI to read]');
|
addMsg(fromLabel, result.text, false);
|
||||||
} catch(e) {
|
} catch(e) {
|
||||||
addSys('[failed to process message]');
|
// Try with existing session
|
||||||
|
try {
|
||||||
|
const bytes = Uint8Array.from(atob(b64), c => c.charCodeAt(0));
|
||||||
|
// Brute-force try each session
|
||||||
|
let decrypted = false;
|
||||||
|
for (const [senderFP, sessData] of Object.entries(sessions)) {
|
||||||
|
try {
|
||||||
|
const resultStr = decrypt_wire_message(mySeedHex, bytes, sessData.data);
|
||||||
|
const result = JSON.parse(resultStr);
|
||||||
|
sessions[senderFP] = { data: result.session_data };
|
||||||
|
localStorage.setItem('wz-sessions', JSON.stringify(
|
||||||
|
Object.fromEntries(Object.entries(sessions).map(([k,v]) => [k, v.data]))
|
||||||
|
));
|
||||||
|
let fromLabel = result.sender.slice(0, 19);
|
||||||
|
try {
|
||||||
|
const ar = await fetch(SERVER + '/v1/alias/whois/' + normFP(result.sender));
|
||||||
|
const ad = await ar.json();
|
||||||
|
if (ad.alias) fromLabel = '@' + ad.alias;
|
||||||
|
} catch(e2) {}
|
||||||
|
addMsg(fromLabel, result.text, false);
|
||||||
|
decrypted = true;
|
||||||
|
break;
|
||||||
|
} catch(e2) { continue; }
|
||||||
|
}
|
||||||
|
if (!decrypted) addSys('[message could not be decrypted]');
|
||||||
|
} catch(e2) {
|
||||||
|
addSys('[failed to process message]');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch(e) { /* server offline */ }
|
} catch(e) { /* server offline */ }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Load saved sessions
|
||||||
|
try {
|
||||||
|
const saved = localStorage.getItem('wz-sessions');
|
||||||
|
if (saved) {
|
||||||
|
const parsed = JSON.parse(saved);
|
||||||
|
for (const [k, v] of Object.entries(parsed)) {
|
||||||
|
sessions[k] = { data: v };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch(e) {}
|
||||||
|
|
||||||
// ── UI ──
|
// ── UI ──
|
||||||
|
|
||||||
function ts() {
|
function ts() {
|
||||||
@@ -355,20 +376,17 @@ function addSys(text) {
|
|||||||
// ── Actions ──
|
// ── Actions ──
|
||||||
|
|
||||||
async function doGenerate() {
|
async function doGenerate() {
|
||||||
const s = crypto.getRandomValues(new Uint8Array(32));
|
generateNewIdentity();
|
||||||
await initIdentity(s);
|
|
||||||
document.getElementById('my-fp').textContent = myFingerprint;
|
document.getElementById('my-fp').textContent = myFingerprint;
|
||||||
document.getElementById('my-seed').textContent = toHex(seed);
|
document.getElementById('my-seed').textContent = mySeedHex;
|
||||||
document.getElementById('gen-section').style.display = 'none';
|
document.getElementById('gen-section').style.display = 'none';
|
||||||
document.getElementById('id-section').style.display = 'block';
|
document.getElementById('id-section').style.display = 'block';
|
||||||
}
|
}
|
||||||
|
|
||||||
async function doRecover() {
|
async function doRecover() {
|
||||||
const input = document.getElementById('recover-input').value.trim();
|
const input = document.getElementById('recover-input').value.trim().replace(/\s+/g, '');
|
||||||
try {
|
try {
|
||||||
const s = fromHex(input);
|
initIdentityFromSeed(input);
|
||||||
if (s.length !== 32) throw new Error('need 32 bytes');
|
|
||||||
await initIdentity(s);
|
|
||||||
document.getElementById('my-fp').textContent = myFingerprint;
|
document.getElementById('my-fp').textContent = myFingerprint;
|
||||||
document.getElementById('my-seed').textContent = '(recovered)';
|
document.getElementById('my-seed').textContent = '(recovered)';
|
||||||
document.getElementById('gen-section').style.display = 'none';
|
document.getElementById('gen-section').style.display = 'none';
|
||||||
@@ -453,28 +471,32 @@ async function sendToGroup(groupName, text) {
|
|||||||
const members = data.members.filter(m => m !== myFP);
|
const members = data.members.filter(m => m !== myFP);
|
||||||
if (members.length === 0) { addSys('No other members in group'); return; }
|
if (members.length === 0) { addSys('No other members in group'); return; }
|
||||||
|
|
||||||
// Encrypt for each member
|
// Encrypt for each member using WASM (same crypto as CLI)
|
||||||
const messages = [];
|
const messages = [];
|
||||||
for (const memberFP of members) {
|
for (const memberFP of members) {
|
||||||
try {
|
try {
|
||||||
const aesKey = await fetchPeerKey(memberFP);
|
const bundleBytes = await fetchPeerBundle(memberFP);
|
||||||
const encrypted = await aesEncrypt(aesKey, text);
|
let wireBytes;
|
||||||
const envelope = JSON.stringify({
|
if (sessions[memberFP]) {
|
||||||
type: 'web',
|
const sess = WasmSession.restore(sessions[memberFP].data);
|
||||||
from: myFP,
|
wireBytes = sess.encrypt(wasmIdentity, text);
|
||||||
group: groupName,
|
sessions[memberFP].data = sess.save();
|
||||||
ciphertext: toHex(encrypted)
|
} else {
|
||||||
});
|
const sess = WasmSession.initiate(wasmIdentity, bundleBytes);
|
||||||
messages.push({
|
wireBytes = sess.encrypt_key_exchange(wasmIdentity, bundleBytes, text);
|
||||||
to: memberFP,
|
sessions[memberFP] = { data: sess.save() };
|
||||||
message: Array.from(new TextEncoder().encode(envelope))
|
}
|
||||||
});
|
messages.push({ to: memberFP, message: Array.from(wireBytes) });
|
||||||
} catch(e) {
|
} catch(e) {
|
||||||
// Silently skip CLI members (different crypto)
|
// Skip members we can't encrypt for
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Save sessions
|
||||||
|
localStorage.setItem('wz-sessions', JSON.stringify(
|
||||||
|
Object.fromEntries(Object.entries(sessions).map(([k,v]) => [k, v.data]))
|
||||||
|
));
|
||||||
|
|
||||||
if (messages.length === 0) { addSys('No compatible web members to send to'); return; }
|
if (messages.length === 0) { addSys('No members to send to'); return; }
|
||||||
|
|
||||||
await fetch(SERVER + '/v1/groups/' + groupName + '/send', {
|
await fetch(SERVER + '/v1/groups/' + groupName + '/send', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@@ -570,10 +592,15 @@ $input.addEventListener('input', function() {
|
|||||||
this.style.height = Math.min(this.scrollHeight, 120) + 'px';
|
this.style.height = Math.min(this.scrollHeight, 120) + 'px';
|
||||||
});
|
});
|
||||||
|
|
||||||
// Auto-load
|
// Initialize WASM and auto-load
|
||||||
(async function() {
|
(async function() {
|
||||||
if (await loadIdentity()) {
|
try {
|
||||||
enterChat();
|
await initWasm();
|
||||||
|
if (loadSavedIdentity()) {
|
||||||
|
enterChat();
|
||||||
|
}
|
||||||
|
} catch(e) {
|
||||||
|
addSys('Failed to load WASM crypto: ' + e.message);
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
25
warzone/crates/warzone-wasm/Cargo.toml
Normal file
25
warzone/crates/warzone-wasm/Cargo.toml
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
[package]
|
||||||
|
name = "warzone-wasm"
|
||||||
|
version.workspace = true
|
||||||
|
edition.workspace = true
|
||||||
|
|
||||||
|
[lib]
|
||||||
|
crate-type = ["cdylib"]
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
warzone-protocol = { path = "../warzone-protocol" }
|
||||||
|
wasm-bindgen = "0.2"
|
||||||
|
serde = { workspace = true }
|
||||||
|
serde_json = { workspace = true }
|
||||||
|
js-sys = "0.3"
|
||||||
|
web-sys = { version = "0.3", features = ["console"] }
|
||||||
|
getrandom = { version = "0.2", features = ["js"] }
|
||||||
|
base64.workspace = true
|
||||||
|
hex.workspace = true
|
||||||
|
bincode.workspace = true
|
||||||
|
x25519-dalek.workspace = true
|
||||||
|
uuid = { version = "1", features = ["v4", "serde", "js"] }
|
||||||
|
|
||||||
|
[profile.release]
|
||||||
|
opt-level = "s"
|
||||||
|
lto = true
|
||||||
239
warzone/crates/warzone-wasm/src/lib.rs
Normal file
239
warzone/crates/warzone-wasm/src/lib.rs
Normal file
@@ -0,0 +1,239 @@
|
|||||||
|
//! WASM bridge: exposes warzone-protocol to JavaScript.
|
||||||
|
//!
|
||||||
|
//! Gives the web client the EXACT same crypto as the CLI:
|
||||||
|
//! X25519, ChaCha20-Poly1305, X3DH, Double Ratchet.
|
||||||
|
|
||||||
|
use wasm_bindgen::prelude::*;
|
||||||
|
|
||||||
|
use warzone_protocol::identity::{IdentityKeyPair, PublicIdentity, Seed};
|
||||||
|
use warzone_protocol::message::WireMessage;
|
||||||
|
use warzone_protocol::prekey::{
|
||||||
|
generate_one_time_pre_keys, generate_signed_pre_key, OneTimePreKeyPublic, PreKeyBundle,
|
||||||
|
};
|
||||||
|
use warzone_protocol::ratchet::RatchetState;
|
||||||
|
use warzone_protocol::x3dh;
|
||||||
|
use x25519_dalek::PublicKey;
|
||||||
|
|
||||||
|
// ── Identity ──
|
||||||
|
|
||||||
|
#[wasm_bindgen]
|
||||||
|
pub struct WasmIdentity {
|
||||||
|
seed_bytes: [u8; 32],
|
||||||
|
#[wasm_bindgen(skip)]
|
||||||
|
pub identity: IdentityKeyPair,
|
||||||
|
#[wasm_bindgen(skip)]
|
||||||
|
pub pub_id: PublicIdentity,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[wasm_bindgen]
|
||||||
|
impl WasmIdentity {
|
||||||
|
#[wasm_bindgen(constructor)]
|
||||||
|
pub fn new() -> WasmIdentity {
|
||||||
|
let seed = Seed::generate();
|
||||||
|
let seed_bytes = seed.0;
|
||||||
|
let identity = seed.derive_identity();
|
||||||
|
let pub_id = identity.public_identity();
|
||||||
|
WasmIdentity { seed_bytes, identity, pub_id }
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn from_hex_seed(hex_seed: &str) -> Result<WasmIdentity, JsValue> {
|
||||||
|
let bytes = hex::decode(hex_seed).map_err(|e| JsValue::from_str(&e.to_string()))?;
|
||||||
|
if bytes.len() != 32 { return Err(JsValue::from_str("seed must be 32 bytes")); }
|
||||||
|
let mut seed_bytes = [0u8; 32];
|
||||||
|
seed_bytes.copy_from_slice(&bytes);
|
||||||
|
let seed = Seed::from_bytes(seed_bytes);
|
||||||
|
let identity = seed.derive_identity();
|
||||||
|
let pub_id = identity.public_identity();
|
||||||
|
Ok(WasmIdentity { seed_bytes, identity, pub_id })
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn fingerprint(&self) -> String { self.pub_id.fingerprint.to_string() }
|
||||||
|
pub fn seed_hex(&self) -> String { hex::encode(self.seed_bytes) }
|
||||||
|
pub fn fingerprint_hex(&self) -> String { self.pub_id.fingerprint.to_hex() }
|
||||||
|
|
||||||
|
pub fn mnemonic(&self) -> String {
|
||||||
|
Seed::from_bytes(self.seed_bytes).to_mnemonic()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Generate pre-key bundle as bincode bytes (for server registration).
|
||||||
|
pub fn bundle_bytes(&self) -> Result<Vec<u8>, JsValue> {
|
||||||
|
let (_, spk) = generate_signed_pre_key(&self.identity, 1);
|
||||||
|
let otpks = generate_one_time_pre_keys(0, 10);
|
||||||
|
let bundle = PreKeyBundle {
|
||||||
|
identity_key: *self.pub_id.signing.as_bytes(),
|
||||||
|
identity_encryption_key: *self.pub_id.encryption.as_bytes(),
|
||||||
|
signed_pre_key: spk,
|
||||||
|
one_time_pre_key: Some(OneTimePreKeyPublic {
|
||||||
|
id: otpks[0].id,
|
||||||
|
public_key: *otpks[0].public.as_bytes(),
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
bincode::serialize(&bundle).map_err(|e| JsValue::from_str(&e.to_string()))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get the signed pre-key secret as hex (needed for X3DH respond / decrypt).
|
||||||
|
/// In a real app this would be stored securely, not exposed.
|
||||||
|
pub fn spk_secret_hex(&self) -> String {
|
||||||
|
let (secret, _) = generate_signed_pre_key(&self.identity, 1);
|
||||||
|
hex::encode(secret.to_bytes())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Session ──
|
||||||
|
|
||||||
|
#[wasm_bindgen]
|
||||||
|
pub struct WasmSession {
|
||||||
|
ratchet: RatchetState,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[wasm_bindgen]
|
||||||
|
impl WasmSession {
|
||||||
|
/// Initiate a new session (Alice side). Returns a WasmSession.
|
||||||
|
pub fn initiate(
|
||||||
|
identity: &WasmIdentity,
|
||||||
|
their_bundle_bytes: &[u8],
|
||||||
|
) -> Result<WasmSession, JsValue> {
|
||||||
|
let bundle: PreKeyBundle = bincode::deserialize(their_bundle_bytes)
|
||||||
|
.map_err(|e| JsValue::from_str(&format!("bundle: {}", e)))?;
|
||||||
|
let result = x3dh::initiate(&identity.identity, &bundle)
|
||||||
|
.map_err(|e| JsValue::from_str(&format!("X3DH: {}", e)))?;
|
||||||
|
let their_spk = PublicKey::from(bundle.signed_pre_key.public_key);
|
||||||
|
Ok(WasmSession {
|
||||||
|
ratchet: RatchetState::init_alice(result.shared_secret, their_spk),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Encrypt a message. Returns bincode-serialized WireMessage (KeyExchange on first, Message after).
|
||||||
|
pub fn encrypt_key_exchange(
|
||||||
|
&mut self,
|
||||||
|
identity: &WasmIdentity,
|
||||||
|
their_bundle_bytes: &[u8],
|
||||||
|
plaintext: &str,
|
||||||
|
) -> Result<Vec<u8>, JsValue> {
|
||||||
|
let bundle: PreKeyBundle = bincode::deserialize(their_bundle_bytes)
|
||||||
|
.map_err(|e| JsValue::from_str(&e.to_string()))?;
|
||||||
|
let result = x3dh::initiate(&identity.identity, &bundle)
|
||||||
|
.map_err(|e| JsValue::from_str(&format!("X3DH: {}", e)))?;
|
||||||
|
|
||||||
|
let encrypted = self.ratchet.encrypt(plaintext.as_bytes())
|
||||||
|
.map_err(|e| JsValue::from_str(&format!("encrypt: {}", e)))?;
|
||||||
|
|
||||||
|
let wire = WireMessage::KeyExchange {
|
||||||
|
sender_fingerprint: identity.pub_id.fingerprint.to_string(),
|
||||||
|
sender_identity_encryption_key: *identity.pub_id.encryption.as_bytes(),
|
||||||
|
ephemeral_public: *result.ephemeral_public.as_bytes(),
|
||||||
|
used_one_time_pre_key_id: result.used_one_time_pre_key_id,
|
||||||
|
ratchet_message: encrypted,
|
||||||
|
};
|
||||||
|
bincode::serialize(&wire).map_err(|e| JsValue::from_str(&e.to_string()))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Encrypt a message for an existing session.
|
||||||
|
pub fn encrypt(&mut self, identity: &WasmIdentity, plaintext: &str) -> Result<Vec<u8>, JsValue> {
|
||||||
|
let encrypted = self.ratchet.encrypt(plaintext.as_bytes())
|
||||||
|
.map_err(|e| JsValue::from_str(&format!("encrypt: {}", e)))?;
|
||||||
|
let wire = WireMessage::Message {
|
||||||
|
sender_fingerprint: identity.pub_id.fingerprint.to_string(),
|
||||||
|
ratchet_message: encrypted,
|
||||||
|
};
|
||||||
|
bincode::serialize(&wire).map_err(|e| JsValue::from_str(&e.to_string()))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Serialize session for localStorage persistence.
|
||||||
|
pub fn save(&self) -> Result<String, JsValue> {
|
||||||
|
let bytes = bincode::serialize(&self.ratchet).map_err(|e| JsValue::from_str(&e.to_string()))?;
|
||||||
|
Ok(base64::Engine::encode(&base64::engine::general_purpose::STANDARD, &bytes))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Restore session from localStorage.
|
||||||
|
pub fn restore(data: &str) -> Result<WasmSession, JsValue> {
|
||||||
|
let bytes = base64::Engine::decode(&base64::engine::general_purpose::STANDARD, data)
|
||||||
|
.map_err(|e| JsValue::from_str(&e.to_string()))?;
|
||||||
|
let ratchet: RatchetState = bincode::deserialize(&bytes)
|
||||||
|
.map_err(|e| JsValue::from_str(&e.to_string()))?;
|
||||||
|
Ok(WasmSession { ratchet })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Decrypt (standalone function, handles both wire message types) ──
|
||||||
|
|
||||||
|
/// Decrypt a bincode WireMessage. Returns JSON string:
|
||||||
|
/// { "sender": "fp", "text": "...", "new_session": bool, "session_data": "base64..." }
|
||||||
|
#[wasm_bindgen]
|
||||||
|
pub fn decrypt_wire_message(
|
||||||
|
identity_hex_seed: &str,
|
||||||
|
message_bytes: &[u8],
|
||||||
|
existing_session_b64: Option<String>,
|
||||||
|
) -> Result<String, JsValue> {
|
||||||
|
let seed_bytes = hex::decode(identity_hex_seed)
|
||||||
|
.map_err(|e| JsValue::from_str(&e.to_string()))?;
|
||||||
|
let mut sb = [0u8; 32];
|
||||||
|
sb.copy_from_slice(&seed_bytes);
|
||||||
|
let seed = Seed::from_bytes(sb);
|
||||||
|
let id = seed.derive_identity();
|
||||||
|
|
||||||
|
let wire: WireMessage = bincode::deserialize(message_bytes)
|
||||||
|
.map_err(|e| JsValue::from_str(&format!("deserialize wire: {}", e)))?;
|
||||||
|
|
||||||
|
match wire {
|
||||||
|
WireMessage::KeyExchange {
|
||||||
|
sender_fingerprint,
|
||||||
|
sender_identity_encryption_key,
|
||||||
|
ephemeral_public,
|
||||||
|
used_one_time_pre_key_id: _,
|
||||||
|
ratchet_message,
|
||||||
|
} => {
|
||||||
|
// For X3DH respond we need the signed pre-key secret.
|
||||||
|
// Re-derive it deterministically from the seed (same as init).
|
||||||
|
let (spk_secret, _) = generate_signed_pre_key(&id, 1);
|
||||||
|
let their_id = PublicKey::from(sender_identity_encryption_key);
|
||||||
|
let their_eph = PublicKey::from(ephemeral_public);
|
||||||
|
|
||||||
|
let shared = x3dh::respond(&id, &spk_secret, None, &their_id, &their_eph)
|
||||||
|
.map_err(|e| JsValue::from_str(&format!("X3DH respond: {}", e)))?;
|
||||||
|
|
||||||
|
let mut ratchet = RatchetState::init_bob(shared, spk_secret);
|
||||||
|
let plain = ratchet.decrypt(&ratchet_message)
|
||||||
|
.map_err(|e| JsValue::from_str(&format!("decrypt: {}", e)))?;
|
||||||
|
|
||||||
|
let session_b64 = base64::Engine::encode(
|
||||||
|
&base64::engine::general_purpose::STANDARD,
|
||||||
|
&bincode::serialize(&ratchet).unwrap_or_default(),
|
||||||
|
);
|
||||||
|
|
||||||
|
Ok(serde_json::json!({
|
||||||
|
"sender": sender_fingerprint,
|
||||||
|
"text": String::from_utf8_lossy(&plain),
|
||||||
|
"new_session": true,
|
||||||
|
"session_data": session_b64,
|
||||||
|
}).to_string())
|
||||||
|
}
|
||||||
|
WireMessage::Message {
|
||||||
|
sender_fingerprint,
|
||||||
|
ratchet_message,
|
||||||
|
} => {
|
||||||
|
let session_data = existing_session_b64
|
||||||
|
.ok_or_else(|| JsValue::from_str("no session for this peer"))?;
|
||||||
|
let session_bytes = base64::Engine::decode(
|
||||||
|
&base64::engine::general_purpose::STANDARD, &session_data,
|
||||||
|
).map_err(|e| JsValue::from_str(&e.to_string()))?;
|
||||||
|
let mut ratchet: RatchetState = bincode::deserialize(&session_bytes)
|
||||||
|
.map_err(|e| JsValue::from_str(&e.to_string()))?;
|
||||||
|
|
||||||
|
let plain = ratchet.decrypt(&ratchet_message)
|
||||||
|
.map_err(|e| JsValue::from_str(&format!("decrypt: {}", e)))?;
|
||||||
|
|
||||||
|
let session_b64 = base64::Engine::encode(
|
||||||
|
&base64::engine::general_purpose::STANDARD,
|
||||||
|
&bincode::serialize(&ratchet).unwrap_or_default(),
|
||||||
|
);
|
||||||
|
|
||||||
|
Ok(serde_json::json!({
|
||||||
|
"sender": sender_fingerprint,
|
||||||
|
"text": String::from_utf8_lossy(&plain),
|
||||||
|
"new_session": false,
|
||||||
|
"session_data": session_b64,
|
||||||
|
}).to_string())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user