v0.0.3: fix X3DH OTPK mismatch — web bundles without OTPKs

Root cause: web client's bundle included OTPKs, so X3DH initiate()
did 4 DH ops (DH4 with OTPK). But decrypt_wire_message() called
respond() with None for OTPK, doing only 3 DH ops.
Different DH concat → different shared secret → decrypt fails.

Fix: web client bundles have one_time_pre_key: None.
initiate() skips DH4 when no OTPK present.
respond() also skips DH4 with None.
Both sides now do exactly 3 DH ops → shared secrets match.

OTPKs are an anti-replay optimization, not required for E2E.
Will add OTPK support to web client in Phase 2 with proper
server-side OTPK storage and consumption tracking.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Siavash Sameni
2026-03-27 09:24:31 +04:00
parent de3b74bb9d
commit 1aba435af3
4 changed files with 13 additions and 19 deletions

10
warzone/Cargo.lock generated
View File

@@ -2555,7 +2555,7 @@ dependencies = [
[[package]]
name = "warzone-client"
version = "0.0.2"
version = "0.0.3"
dependencies = [
"anyhow",
"argon2",
@@ -2584,7 +2584,7 @@ dependencies = [
[[package]]
name = "warzone-mule"
version = "0.0.2"
version = "0.0.3"
dependencies = [
"anyhow",
"clap",
@@ -2593,7 +2593,7 @@ dependencies = [
[[package]]
name = "warzone-protocol"
version = "0.0.2"
version = "0.0.3"
dependencies = [
"base64",
"bincode",
@@ -2616,7 +2616,7 @@ dependencies = [
[[package]]
name = "warzone-server"
version = "0.0.2"
version = "0.0.3"
dependencies = [
"anyhow",
"axum",
@@ -2642,7 +2642,7 @@ dependencies = [
[[package]]
name = "warzone-wasm"
version = "0.0.2"
version = "0.0.3"
dependencies = [
"base64",
"bincode",

View File

@@ -9,7 +9,7 @@ members = [
]
[workspace.package]
version = "0.0.2"
version = "0.0.3"
edition = "2021"
license = "MIT"
rust-version = "1.75"

View File

@@ -160,7 +160,7 @@ let peerBundles = {}; // peerFP -> bundle bytes
let pollTimer = null;
let wasmReady = false;
const VERSION = '0.0.2';
const VERSION = '0.0.3';
let DEBUG = true; // toggle with /debug command
function dbg(...args) {

View File

@@ -8,7 +8,7 @@ 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,
generate_signed_pre_key, PreKeyBundle,
};
use warzone_protocol::ratchet::RatchetState;
use warzone_protocol::x3dh;
@@ -115,16 +115,14 @@ impl WasmIdentity {
timestamp: js_sys::Date::now() as i64 / 1000,
};
let otpks = generate_one_time_pre_keys(0, 10);
// No OTPKs for web client (can't store secrets for them reliably).
// initiate() will skip DH4 when one_time_pre_key is None.
// This is safe — OTPKs are an anti-replay optimization, not required.
Ok(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(),
}),
one_time_pre_key: None,
})
}
}
@@ -222,15 +220,11 @@ pub fn self_test() -> Result<String, JsValue> {
// Bob's pre-key bundle
let (bob_spk_secret, bob_spk) = generate_signed_pre_key(&bob_id, 1);
let bob_spk_secret_bytes = bob_spk_secret.to_bytes();
let bob_otpks = generate_one_time_pre_keys(0, 5);
let bob_bundle = PreKeyBundle {
identity_key: *bob_pub.signing.as_bytes(),
identity_encryption_key: *bob_pub.encryption.as_bytes(),
signed_pre_key: bob_spk,
one_time_pre_key: Some(OneTimePreKeyPublic {
id: bob_otpks[0].id,
public_key: *bob_otpks[0].public.as_bytes(),
}),
one_time_pre_key: None,
};
let _bob_bundle_bytes = bincode::serialize(&bob_bundle)
.map_err(|e| JsValue::from_str(&e.to_string()))?;