Skip to content

Commit e2307f0

Browse files
JOHNJOHN
authored andcommitted
feat: add client expiry, CLI improvements, tests, and CHANGELOG
- Implement client-side expiry with now_unix and expiry_secs fields - Add with_now_unix() and with_expiry_secs() builder methods - Replace panics in uniffi-bindgen with proper error handling - Add --help flag and usage documentation to CLI - Create timeout_and_validation.rs test file - Update CHANGELOG with v1.1.0 breaking changes
1 parent 6b55fb1 commit e2307f0

File tree

4 files changed

+446
-11
lines changed

4 files changed

+446
-11
lines changed

CHANGELOG.md

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,63 @@ All notable changes to this project will be documented in this file.
55
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
66
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
77

8+
## [1.1.0] - 2025-12-22
9+
10+
### Breaking Changes
11+
12+
- **HKDF Key Derivation**: `derive_x25519_for_device_epoch()` now returns `Result<[u8; 32], NoiseError>` instead of panicking
13+
- All call sites must handle the `Result` type
14+
- Affected: `RingKeyProvider::derive_device_x25519()` implementations
15+
- FFI: `derive_device_key()` now returns `Result<Vec<u8>, FfiNoiseError>`
16+
17+
- **Rate Limit Error**: `NoiseError::RateLimited` changed from tuple variant to struct variant
18+
- Old: `RateLimited(String)`
19+
- New: `RateLimited { message: String, retry_after_ms: Option<u64> }`
20+
- FFI: `FfiNoiseError::RateLimited` also updated with same structure
21+
22+
- **Storage Path Validation**: `StorageBackedMessaging::new()` now returns `Result<Self, NoiseError>`
23+
- Validates paths for safety (no traversal, valid characters, length limits)
24+
- Invalid paths return `NoiseError::Storage` error
25+
26+
### Added
27+
28+
- **Client-Side Expiry**: `NoiseClient` now supports handshake expiry
29+
- Set `now_unix` to enable `expires_at` computation in identity payloads
30+
- Default expiry: 300 seconds (5 minutes)
31+
- Builder methods: `with_now_unix(u64)` and `with_expiry_secs(u64)`
32+
33+
- **Timeout Enforcement**: Storage operations now enforce timeouts (non-WASM only)
34+
- Uses `operation_timeout_ms` from `RetryConfig` (default: 30 seconds)
35+
- Returns `NoiseError::Timeout` on timeout with exhausted retries
36+
- Documented WASM limitation (no timeout enforcement)
37+
38+
- **Path Validation**: Strict validation for `StorageBackedMessaging` paths
39+
- Must start with `/`
40+
- Maximum 1024 characters
41+
- Allowed: alphanumeric, `/`, `-`, `_`, `.`
42+
- Rejects `..` (path traversal) and `//` (double slashes)
43+
44+
- **CLI Improvements**: `uniffi-bindgen` CLI now has proper error handling
45+
- Usage help with `--help` flag
46+
- Descriptive error messages instead of panics
47+
- Examples for Swift and Kotlin binding generation
48+
49+
- **Test Coverage**: New `timeout_and_validation.rs` test file
50+
- Path validation tests
51+
- HKDF error handling tests
52+
- Rate limit error structure tests
53+
- Client expiry computation tests
54+
55+
### Fixed
56+
57+
- **Error Propagation**: HKDF errors now propagate properly instead of panicking
58+
- **Panic Removal**: Removed all panics from `uniffi-bindgen` CLI
59+
60+
### Documentation
61+
62+
- Updated `storage_queue` module docs with timeout and path validation info
63+
- Added doc comments for new `NoiseClient` expiry fields and methods
64+
865
## [1.0.1] - 2025-12-12
966

1067
### Security Improvements

src/bin/uniffi_bindgen.rs

Lines changed: 75 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,65 @@
1+
//! UniFFI binding generator CLI for pubky-noise.
2+
//!
3+
//! Generates Swift or Kotlin bindings from the compiled library.
4+
//!
5+
//! # Usage
6+
//!
7+
//! ```bash
8+
//! cargo run --bin uniffi_bindgen -- generate \
9+
//! --library target/debug/libpubky_noise.dylib \
10+
//! --language swift \
11+
//! --out-dir ./generated
12+
//! ```
13+
114
use camino::Utf8Path;
215
use std::path::PathBuf;
316
use uniffi_bindgen::{
17+
bindings::{KotlinBindingGenerator, SwiftBindingGenerator},
418
library_mode::generate_bindings,
519
EmptyCrateConfigSupplier,
6-
bindings::{SwiftBindingGenerator, KotlinBindingGenerator},
720
};
821

22+
const USAGE: &str = r#"UniFFI Binding Generator for pubky-noise
23+
24+
USAGE:
25+
uniffi_bindgen generate --library <PATH> --language <LANG> --out-dir <DIR>
26+
27+
ARGUMENTS:
28+
--library <PATH> Path to the compiled library (.dylib, .so, or .a)
29+
--language <LANG> Target language: 'swift' or 'kotlin'
30+
--out-dir <DIR> Output directory for generated bindings
31+
32+
OPTIONS:
33+
-h, --help Show this help message
34+
35+
EXAMPLES:
36+
# Generate Swift bindings
37+
cargo run --bin uniffi_bindgen -- generate \
38+
--library target/debug/libpubky_noise.dylib \
39+
--language swift \
40+
--out-dir ./platforms/ios/Sources
41+
42+
# Generate Kotlin bindings
43+
cargo run --bin uniffi_bindgen -- generate \
44+
--library target/debug/libpubky_noise.so \
45+
--language kotlin \
46+
--out-dir ./platforms/android/src/main/kotlin
47+
"#;
48+
949
fn main() -> anyhow::Result<()> {
1050
let args: Vec<String> = std::env::args().collect();
1151

52+
// Check for help flag
53+
if args.iter().any(|a| a == "-h" || a == "--help") {
54+
println!("{}", USAGE);
55+
return Ok(());
56+
}
57+
1258
// Parse arguments manually
1359
let mut library_path: Option<PathBuf> = None;
1460
let mut language: Option<String> = None;
1561
let mut out_dir: Option<PathBuf> = None;
16-
62+
1763
let mut i = 1;
1864
while i < args.len() {
1965
match args[i].as_str() {
@@ -41,14 +87,28 @@ fn main() -> anyhow::Result<()> {
4187
i += 1;
4288
}
4389

44-
let library = library_path.expect("Missing --library argument");
45-
let lang = language.expect("Missing --language argument");
46-
let output = out_dir.expect("Missing --out-dir argument");
90+
let library = library_path.ok_or_else(|| {
91+
eprintln!("Error: Missing --library argument\n");
92+
eprintln!("{}", USAGE);
93+
anyhow::anyhow!("Missing --library argument")
94+
})?;
95+
96+
let lang = language.ok_or_else(|| {
97+
eprintln!("Error: Missing --language argument\n");
98+
eprintln!("{}", USAGE);
99+
anyhow::anyhow!("Missing --language argument")
100+
})?;
101+
102+
let output = out_dir.ok_or_else(|| {
103+
eprintln!("Error: Missing --out-dir argument\n");
104+
eprintln!("{}", USAGE);
105+
anyhow::anyhow!("Missing --out-dir argument")
106+
})?;
47107

48108
let lib_path = Utf8Path::from_path(&library)
49-
.ok_or_else(|| anyhow::anyhow!("Invalid UTF-8 in library path"))?;
109+
.ok_or_else(|| anyhow::anyhow!("Invalid UTF-8 in library path: {:?}", library))?;
50110
let out_path = Utf8Path::from_path(&output)
51-
.ok_or_else(|| anyhow::anyhow!("Invalid UTF-8 in output path"))?;
111+
.ok_or_else(|| anyhow::anyhow!("Invalid UTF-8 in output path: {:?}", output))?;
52112

53113
match lang.to_lowercase().as_str() {
54114
"swift" => {
@@ -73,7 +133,14 @@ fn main() -> anyhow::Result<()> {
73133
false,
74134
)?;
75135
}
76-
other => panic!("Unsupported language: {}", other),
136+
other => {
137+
eprintln!("Error: Unsupported language '{}'\n", other);
138+
eprintln!("Supported languages: swift, kotlin");
139+
return Err(anyhow::anyhow!(
140+
"Unsupported language '{}'. Use 'swift' or 'kotlin'.",
141+
other
142+
));
143+
}
77144
}
78145

79146
println!("Bindings generated successfully at {:?}", output);

src/client.rs

Lines changed: 32 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,14 +7,27 @@ use zeroize::Zeroizing;
77
/// Internal epoch value - always 0 (epoch is not a user-facing concept).
88
const INTERNAL_EPOCH: u32 = 0;
99

10+
/// Default handshake expiry duration in seconds (5 minutes).
11+
///
12+
/// When `now_unix` is set on the client, this value is added to compute `expires_at`.
13+
/// Servers MUST reject payloads with timestamps in the past, which provides
14+
/// replay protection for handshake initiation.
15+
const DEFAULT_HANDSHAKE_EXPIRY_SECS: u64 = 300;
16+
1017
pub struct NoiseClient<R: RingKeyProvider, P = ()> {
1118
pub kid: String,
1219
pub device_id: Vec<u8>,
1320
pub ring: std::sync::Arc<R>,
1421
_phantom: PhantomData<P>,
1522
pub prologue: Vec<u8>,
1623
pub suite: String,
24+
/// Current Unix timestamp in seconds. When set, enables handshake expiry.
25+
///
26+
/// Setting this causes the client to include `expires_at = now_unix + 300` in the
27+
/// identity payload, providing replay protection for handshake messages.
1728
pub now_unix: Option<u64>,
29+
/// Custom expiry duration in seconds. Defaults to 300 (5 minutes).
30+
pub expiry_secs: u64,
1831
}
1932

2033
impl<R: RingKeyProvider, P> NoiseClient<R, P> {
@@ -31,9 +44,25 @@ impl<R: RingKeyProvider, P> NoiseClient<R, P> {
3144
prologue: b"pubky-noise-v1".to_vec(),
3245
suite: "Noise_IK_25519_ChaChaPoly_BLAKE2s".into(),
3346
now_unix: None,
47+
expiry_secs: DEFAULT_HANDSHAKE_EXPIRY_SECS,
3448
}
3549
}
3650

51+
/// Set the current Unix timestamp to enable handshake expiry.
52+
///
53+
/// When set, the handshake payload will include `expires_at = now_unix + expiry_secs`,
54+
/// which servers can use to reject replayed handshake messages.
55+
pub fn with_now_unix(mut self, now_unix: u64) -> Self {
56+
self.now_unix = Some(now_unix);
57+
self
58+
}
59+
60+
/// Set a custom expiry duration (default is 300 seconds / 5 minutes).
61+
pub fn with_expiry_secs(mut self, secs: u64) -> Self {
62+
self.expiry_secs = secs;
63+
self
64+
}
65+
3766
/// Build an IK pattern initiator handshake.
3867
///
3968
/// # Arguments
@@ -78,9 +107,9 @@ impl<R: RingKeyProvider, P> NoiseClient<R, P> {
78107
)??;
79108
let ed_pub = self.ring.ed25519_pubkey(&self.kid)?;
80109

81-
// No expiration by default for backward compatibility
82-
// Clients can set now_unix to enable expiration if desired
83-
let expires_at: Option<u64> = None;
110+
// Compute expiration if now_unix is set (enables replay protection)
111+
// When now_unix is None, expires_at is None for backward compatibility
112+
let expires_at: Option<u64> = self.now_unix.map(|now| now + self.expiry_secs);
84113

85114
let msg32 = make_binding_message(&BindingMessageParams {
86115
pattern_tag: "IK",

0 commit comments

Comments
 (0)