Skip to content

Commit b55a935

Browse files
JOHNJOHN
authored andcommitted
feat: add XX/NN patterns, secure-mem, handshake-queue, path normalization
- Implement full XX pattern (TOFU) with identity binding - Add NN pattern with explicit security warnings - Add HandshakeQueue for async storage-backed handshakes - Add secure-mem feature with LockedBytes (RAII mlock) - Fix path normalization to prevent // in storage paths - Align FFI mutex poisoning with availability-first strategy - Regenerate Swift/Kotlin bindings - Add fuzz targets for XX and NN patterns - Fix docs to clarify reconnection is app-implemented
1 parent f0c5cee commit b55a935

33 files changed

+6021
-1131
lines changed

Cargo.toml

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,8 +13,8 @@ crate-type = ["cdylib", "staticlib", "rlib"]
1313
default = []
1414
pkarr = []
1515
trace = ["tracing"]
16-
# secure-mem: Reserved for future mlock-based memory protection.
17-
# Currently a no-op; applications should use platform secure storage for keys.
16+
# secure-mem: Best-effort mlock-based memory protection for key material.
17+
# Uses region crate for platform-specific memory locking. Falls back silently if unavailable.
1818
secure-mem = ["region"]
1919
pubky-sdk = ["dep:pubky"]
2020
storage-queue = ["dep:pubky", "dep:async-trait", "dep:tokio"]
@@ -42,7 +42,7 @@ tracing = { version = "0.1", optional = true }
4242
region = { version = "3", optional = true }
4343
pubky = { git = "https://github.com/BitcoinErrorLog/pubky-core", rev = "290d801", optional = true }
4444
async-trait = { version = "0.1", optional = true }
45-
tokio = { version = "1", features = ["rt"], optional = true }
45+
tokio = { version = "1", features = ["rt", "time"], optional = true }
4646
uniffi = { version = "0.29.4", optional = true }
4747
uniffi_bindgen = { version = "0.29.4", optional = true }
4848
camino = { version = "1.1", optional = true }

README.md

Lines changed: 59 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -25,15 +25,15 @@ Direct client↔server Noise sessions for Pubky using `snow`. Default build is d
2525
## Specs and suites
2626

2727
* Noise revision: 34 (as implemented by current `snow`).
28-
* Suites: `Noise_XX_25519_ChaChaPoly_BLAKE2s` and `Noise_IK_25519_ChaChaPoly_BLAKE2s`.
28+
* Suites: `Noise_XX_25519_ChaChaPoly_BLAKE2s`, `Noise_IK_25519_ChaChaPoly_BLAKE2s`, and `Noise_NN_25519_ChaChaPoly_BLAKE2s`.
2929
* Hash: BLAKE2s. AEAD: ChaCha20-Poly1305. DH: X25519.
3030

3131
## Features
3232

3333
* `default = []`: direct-only, no PKARR, no extra dependencies.
3434
* `pkarr`: optional signed metadata fetch and verification for server static and epoch.
3535
* `trace`: opt-in `tracing` for non-sensitive logs.
36-
* `secure-mem`: reserved for future best-effort memory hardening (currently a no-op).
36+
* `secure-mem`: Best-effort memory hardening using platform mlock. Use `LockedBytes<N>` wrapper for sensitive key material.
3737
* `pubky-sdk`: Convenience wrapper for `RingKeyProvider` using Pubky SDK `Keypair`.
3838
* `storage-queue`: Support for storage-backed messaging using Pubky storage as a queue (requires `pubky` and `async-trait`).
3939

@@ -149,19 +149,69 @@ queue = queue.with_retry_config(retry_config);
149149

150150
## Handshake flows
151151

152-
### First contact (TOFU or OOB token)
152+
### IK Pattern: Pinned server static (recommended)
153153

154-
* Pattern: `XX`.
155-
* Client: `NoiseClient::build_initiator_xx_tofu(hint) -> (HandshakeState, first_msg)`.
156-
* Server: `NoiseServer::build_responder_read_xx(first_msg) -> HandshakeState`.
157-
* Caller pins the server static post-handshake through an out-of-band path, then uses IK for future connections.
158-
159-
### Pinned server static
154+
When you already know the server's static key (from a previous XX handshake or out-of-band):
160155

161156
* Pattern: `IK`.
162157
* Client: `NoiseClient::build_initiator_ik_direct(server_static_pub, hint) -> (HandshakeState, first_msg)`.
163158
* Server: `NoiseServer::build_responder_read_ik(first_msg) -> (HandshakeState, IdentityPayload)`.
164159

160+
### XX Pattern: First contact (TOFU)
161+
162+
For first contact when the server's static key is unknown:
163+
164+
* Pattern: `XX`.
165+
* Client: `NoiseClient::build_initiator_xx_tofu(hint) -> (HandshakeState, first_msg, hint)`.
166+
* Server: `NoiseServer::build_responder_xx(first_msg) -> (HandshakeState, response, server_pk)`.
167+
* Client: `NoiseClient::complete_initiator_xx(hs, response, hint) -> (HandshakeState, final_msg, server_identity, server_pk)`.
168+
* Server: `NoiseServer::complete_responder_xx(hs, final_msg, server_pk) -> (HandshakeState, client_identity)`.
169+
* **After handshake**: Pin the learned `server_pk` and use IK for future connections.
170+
171+
```rust
172+
use pubky_noise::datalink_adapter::{
173+
client_start_xx_tofu, server_accept_xx, client_complete_xx, server_complete_xx
174+
};
175+
176+
// Step 1: Client initiates (no server key needed)
177+
let init = client_start_xx_tofu(&client, Some("server.example.com"))?;
178+
179+
// Step 2: Server accepts and responds with identity
180+
let (s_hs, response, server_pk) = server_accept_xx(&server, &init.first_msg)?;
181+
182+
// Step 3a: Client completes and learns server's key
183+
let (result, final_msg) = client_complete_xx(&client, init.hs, &response, init.server_hint.as_deref())?;
184+
185+
// Step 3b: Server completes
186+
let (s_link, client_id) = server_complete_xx(&server, s_hs, &final_msg, &server_pk)?;
187+
188+
// Pin server_pk for future IK connections!
189+
save_pinned_key(result.server_static_pk);
190+
```
191+
192+
### NN Pattern: Ephemeral-only (NO AUTHENTICATION)
193+
194+
> ⚠️ **Security Warning**: The NN pattern provides **forward secrecy only** with NO identity binding. An active attacker can trivially MITM this connection. Use ONLY when:
195+
> - The transport layer provides authentication (e.g., TLS with pinned certs)
196+
> - You are building a higher-level authenticated protocol on top
197+
> - You explicitly accept the MITM risk for your use case
198+
199+
* Pattern: `NN`.
200+
* Client: `NoiseClient::build_initiator_nn() -> (HandshakeState, first_msg)`.
201+
* Server: `NoiseServer::build_responder_nn(first_msg) -> (HandshakeState, response)`.
202+
* Client: `NoiseClient::complete_initiator_nn(hs, response) -> HandshakeState`.
203+
204+
```rust
205+
use pubky_noise::datalink_adapter::{client_start_nn, server_accept_nn, client_complete_nn, server_complete_nn};
206+
207+
// WARNING: NO AUTHENTICATION!
208+
let (c_hs, first_msg) = client_start_nn(&client)?;
209+
let (s_hs, response) = server_accept_nn(&server, &first_msg)?;
210+
let c_link = client_complete_nn(&client, c_hs, &response)?;
211+
let s_link = server_complete_nn(s_hs)?;
212+
// DANGER: You have NO cryptographic proof of who you're talking to!
213+
```
214+
165215
## Quick start
166216

167217
### Build and test

THREAT_MODEL.md

Lines changed: 37 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,8 +34,9 @@
3434
│ └─────────────────────────────────────────┘ │
3535
│ ┌─────────────────────────────────────────┐ │
3636
│ │ Noise Protocol (via snow library) │ │
37-
│ │ - XX pattern (first contact) │ │
3837
│ │ - IK pattern (known server) │ │
38+
│ │ - XX pattern (first contact/TOFU) │ │
39+
│ │ - NN pattern (ephemeral-only)* │ │
3940
│ └─────────────────────────────────────────┘ │
4041
│ ┌─────────────────────────────────────────┐ │
4142
│ │ Key Management (Ring/Pubky SDK) │ │
@@ -498,6 +499,41 @@ FFI errors are mapped to structured error codes:
498499

499500
---
500501

502+
### 5. NN Pattern (Ephemeral-Only) ⚠️ HIGH RISK
503+
504+
**CRITICAL WARNING**: The NN pattern provides **ZERO AUTHENTICATION**.
505+
506+
**Security Properties**:
507+
- ✅ Forward secrecy (ephemeral keys)
508+
- ❌ NO identity binding
509+
- ❌ NO impersonation protection
510+
- ❌ Trivial MITM attacks possible
511+
512+
**Attack Scenario**:
513+
```
514+
Client ←→ [Active Attacker] ←→ Server
515+
```
516+
An active attacker can:
517+
1. Intercept client's ephemeral key
518+
2. Complete handshake with client using attacker's ephemeral
519+
3. Complete separate handshake with server using attacker's ephemeral
520+
4. Decrypt all traffic, re-encrypt for the other party
521+
522+
**Valid Use Cases**:
523+
- Transport layer already provides authentication (TLS with pinned certs)
524+
- Building higher-level authenticated protocol on top
525+
- Testing/development environments
526+
- Explicit acceptance of MITM risk for specific use case
527+
528+
**NEVER Use For**:
529+
- Production systems without external authentication
530+
- Any system requiring identity verification
531+
- Financial or sensitive data transfer
532+
533+
**Recommendation**: Use IK (known peer) or XX (TOFU) patterns instead. NN is included only for completeness and specific transport-layer integration scenarios.
534+
535+
---
536+
501537
## Security Checklist for Applications
502538

503539
### ✅ MUST Implement

docs/MOBILE_INTEGRATION.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ This guide explains how to integrate `pubky-noise` into mobile applications (iOS
2424
├─────────────────────────────────────────────────┤
2525
│ NoiseManager (High-level lifecycle) │
2626
│ ├─ Session State Persistence │
27-
│ ├─ Automatic Reconnection
27+
│ ├─ Reconnection Hints (app-implemented)
2828
│ └─ Mobile Configuration │
2929
├─────────────────────────────────────────────────┤
3030
│ StorageBackedMessaging (Optional) │

docs/archive/IMPLEMENTATION_SUMMARY.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -144,7 +144,7 @@ See `docs/FFI_IMPLEMENTATION_SUMMARY.md` for detailed FFI implementation specifi
144144
### 4. Mobile Optimization
145145
- Battery saver mode
146146
- Mobile-friendly chunk sizes (32KB default)
147-
- Automatic reconnection
147+
- Reconnection hints (app-implemented)
148148
- Memory-efficient operation
149149

150150
### 5. Error Handling

fuzz/Cargo.toml

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,3 +51,17 @@ test = false
5151
doc = false
5252
bench = false
5353

54+
[[bin]]
55+
name = "fuzz_xx_handshake"
56+
path = "fuzz_targets/fuzz_xx_handshake.rs"
57+
test = false
58+
doc = false
59+
bench = false
60+
61+
[[bin]]
62+
name = "fuzz_nn_handshake"
63+
path = "fuzz_targets/fuzz_nn_handshake.rs"
64+
test = false
65+
doc = false
66+
bench = false
67+
Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
#![no_main]
2+
3+
//! Fuzz target for NN pattern handshake message parsing.
4+
//!
5+
//! Tests that the server gracefully handles malformed NN handshake messages
6+
//! without panicking.
7+
//!
8+
//! # Security Note
9+
//!
10+
//! The NN pattern provides NO authentication. This fuzz target tests
11+
//! implementation robustness, not security properties.
12+
13+
use arbitrary::Arbitrary;
14+
use libfuzzer_sys::fuzz_target;
15+
use pubky_noise::{NoiseClient, NoiseError, NoiseServer, RingKeyProvider};
16+
use std::sync::Arc;
17+
18+
/// A simple key provider for fuzzing
19+
struct FuzzRing {
20+
seed: [u8; 32],
21+
}
22+
23+
impl RingKeyProvider for FuzzRing {
24+
fn derive_device_x25519(
25+
&self,
26+
_kid: &str,
27+
device_id: &[u8],
28+
epoch: u32,
29+
) -> Result<[u8; 32], NoiseError> {
30+
pubky_noise::kdf::derive_x25519_for_device_epoch(&self.seed, device_id, epoch)
31+
}
32+
33+
fn ed25519_pubkey(&self, _kid: &str) -> Result<[u8; 32], NoiseError> {
34+
use sha2::{Digest, Sha256};
35+
let mut hasher = Sha256::new();
36+
hasher.update(&self.seed);
37+
hasher.update(b"ed25519_pubkey");
38+
let result = hasher.finalize();
39+
let mut pk = [0u8; 32];
40+
pk.copy_from_slice(&result[..32]);
41+
Ok(pk)
42+
}
43+
44+
fn sign_ed25519(&self, _kid: &str, _msg: &[u8]) -> Result<[u8; 64], NoiseError> {
45+
Ok([0x42u8; 64])
46+
}
47+
}
48+
49+
/// Arbitrary input for NN handshake fuzzing
50+
#[derive(Debug, Arbitrary)]
51+
struct NnInput {
52+
client_seed: [u8; 32],
53+
server_seed: [u8; 32],
54+
/// Fuzzed first message (should be ephemeral key only)
55+
malformed_first_msg: Vec<u8>,
56+
/// Fuzzed response (should be server ephemeral + ee)
57+
malformed_response: Vec<u8>,
58+
}
59+
60+
fuzz_target!(|input: NnInput| {
61+
// Skip very large messages to avoid OOM
62+
if input.malformed_first_msg.len() > 65536 || input.malformed_response.len() > 65536 {
63+
return;
64+
}
65+
66+
let client_ring = Arc::new(FuzzRing {
67+
seed: input.client_seed,
68+
});
69+
let server_ring = Arc::new(FuzzRing {
70+
seed: input.server_seed,
71+
});
72+
73+
let client = NoiseClient::<_, ()>::new_direct("fuzz_client", b"client", client_ring);
74+
let server = NoiseServer::<_, ()>::new_direct("fuzz_server", b"server", server_ring);
75+
76+
// Test 1: Server handling malformed NN first message
77+
// This should NOT panic, just return an error
78+
let nn_result = server.build_responder_nn(&input.malformed_first_msg);
79+
match nn_result {
80+
Ok((_hs, _response)) => {
81+
// If somehow valid, that's fine - NN is lenient
82+
}
83+
Err(_) => {
84+
// Expected - malformed messages should be rejected gracefully
85+
}
86+
}
87+
88+
// Test 2: Valid NN initiation followed by malformed response handling
89+
let init_result = client.build_initiator_nn();
90+
if let Ok((hs, first_msg)) = init_result {
91+
// Server processes valid first message
92+
if let Ok((_server_hs, response)) = server.build_responder_nn(&first_msg) {
93+
// Client tries to complete with fuzzed data instead of valid response
94+
let complete_result = client.complete_initiator_nn(hs, &input.malformed_response);
95+
// Should not panic
96+
let _ = complete_result;
97+
}
98+
}
99+
100+
// Test 3: Test with random first message, valid response flow
101+
if let Ok((hs, _)) = client.build_initiator_nn() {
102+
// Pass malformed data as first message to server
103+
if let Ok((_, valid_response)) = server.build_responder_nn(&input.malformed_first_msg) {
104+
// Client completes with this response (may fail, but shouldn't panic)
105+
let _ = client.complete_initiator_nn(hs, &valid_response);
106+
}
107+
}
108+
});
109+

0 commit comments

Comments
 (0)