Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ Hosted clients should treat `bifrost-rs` as the signer authority.

- `runtime_status()` is the canonical aggregated read model.
- `readiness()` is the narrower capability view.
- `peer_status()` reports peer capability, policy, latency, and nonce-inventory telemetry for operator UIs.
- `drain_runtime_events()` is incremental and lossy-safe; clients must recover truth from `runtime_status()`.
- `prepare_sign()` and `prepare_ecdh()` are the normal operation-prep APIs.
- `wipe_state()` is the canonical signer-side reset path.
Expand Down
5 changes: 3 additions & 2 deletions crates/bifrost-app/src/host/client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -192,9 +192,10 @@ mod tests {

#[cfg(unix)]
fn test_socket_path(name: &str) -> PathBuf {
let short_name = name.chars().next().unwrap_or('x');
let unique = format!(
"bifrost-app-client-{}-{}-{}.sock",
name,
"bac-{}-{}-{}.sock",
short_name,
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
Expand Down
3 changes: 2 additions & 1 deletion crates/bifrost-app/tests/daemon_control_errors.rs
Original file line number Diff line number Diff line change
Expand Up @@ -15,12 +15,13 @@ fn token_with_byte(b: u8) -> DaemonToken {
}

fn temp_path(name: &str, suffix: &str) -> PathBuf {
let short_name = name.chars().next().unwrap_or('x');
let nonce = SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("clock")
.as_nanos();
std::env::temp_dir().join(format!(
"bifrost-daemon-errors-{name}-{}-{nonce}.{suffix}",
"bde-{short_name}-{}-{nonce}.{suffix}",
std::process::id()
))
}
Expand Down
3 changes: 2 additions & 1 deletion crates/bifrost-app/tests/daemon_lifecycle.rs
Original file line number Diff line number Diff line change
Expand Up @@ -19,12 +19,13 @@ fn token_with_byte(b: u8) -> DaemonToken {
}

fn temp_path(name: &str, suffix: &str) -> PathBuf {
let short_name = name.chars().next().unwrap_or('x');
let nonce = SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("system clock before unix epoch")
.as_nanos();
std::env::temp_dir().join(format!(
"bifrost-daemon-{name}-{}-{nonce}.{suffix}",
"bd-{short_name}-{}-{nonce}.{suffix}",
std::process::id()
))
}
Expand Down
3 changes: 3 additions & 0 deletions crates/bifrost-bridge-tokio/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -847,6 +847,9 @@ fn completed_operation_kind(operation: &CompletedOperation) -> &'static str {
CompletedOperation::Sign { .. } => "sign",
CompletedOperation::Ecdh { .. } => "ecdh",
CompletedOperation::Ping { .. } => "ping",
CompletedOperation::PingServed { .. } => "ping_served",
CompletedOperation::EcdhServed { .. } => "ecdh_served",
CompletedOperation::SignServed { .. } => "sign_served",
CompletedOperation::Onboard { .. } => "onboard",
CompletedOperation::OnboardServed { .. } => "onboard_served",
}
Expand Down
119 changes: 119 additions & 0 deletions crates/bifrost-bridge-wasm/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -286,6 +286,18 @@ enum CompletedOperationJson {
request_id: String,
peer: String,
},
PingServed {
request_id: String,
peer_pubkey32_hex: String,
},
EcdhServed {
request_id: String,
peer_pubkey32_hex: String,
},
SignServed {
request_id: String,
peer_pubkey32_hex: String,
},
Onboard {
request_id: String,
group_member_count: usize,
Expand Down Expand Up @@ -1587,6 +1599,92 @@ mod tests {
assert!(err.to_string().contains("message_hex_32"));
}

#[test]
fn inbound_sign_nonce_miss_surfaces_sign_failure() {
let bundle =
create_keyset(CreateKeysetConfig::new("Test Group", 2, 2)).expect("create keyset");
let group = bundle.group.clone();
let alice_share = bundle.shares[0].clone();
let bob_share = bundle.shares[1].clone();
let bob_peer = hex::encode(&group.members[1].pubkey[1..]);
let mut bob_seed_state = DeviceState::new(bob_share.idx, *bob_share.seckey.expose_bytes());
let stale_bob_nonces = bob_seed_state
.nonce_pool
.generate_for_peer(
alice_share.idx,
10,
&bob_seed_state.secrets.nonce_pool_secret,
)
.expect("generate stale bob nonces");

let alice_bootstrap = RuntimeBootstrapInput {
group: GroupPackageWire::from(group.clone()),
share: SharePackageWire::from(alice_share),
peers: vec![bob_peer.clone()],
initial_peer_nonces: vec![BootstrapPeerNoncesInput {
peer: bob_peer.clone(),
nonces: stale_bob_nonces.into_iter().map(Into::into).collect(),
}],
};
let bob_bootstrap = RuntimeBootstrapInput {
group: GroupPackageWire::from(group),
share: SharePackageWire::from(bob_share),
peers: vec![hex::encode(&bundle.group.members[0].pubkey[1..])],
initial_peer_nonces: Vec::new(),
};
let now = 1_700_000_000_000u64;

let mut alice = WasmBridgeRuntime::new();
alice
.init_runtime(
"{}".to_string(),
serde_json::to_string(&alice_bootstrap).expect("alice bootstrap"),
)
.expect("init alice");
let mut bob = WasmBridgeRuntime::new();
bob.init_runtime(
"{}".to_string(),
serde_json::to_string(&bob_bootstrap).expect("bob bootstrap"),
)
.expect("init bob");

alice
.handle_command(
serde_json::json!({
"type": "sign",
"message_hex_32": hex::encode([0x77; 32])
})
.to_string(),
)
.expect("queue sign");
alice.tick(now).expect("tick alice");
let outbound: Vec<Event> =
serde_json::from_str(&alice.drain_outbound_events().expect("alice outbound"))
.expect("decode alice outbound");
assert_eq!(outbound.len(), 1);

bob.handle_inbound_event(
serde_json::to_string(&outbound[0]).expect("encode inbound event"),
)
.expect("bob handle inbound");
bob.tick(now + 1).expect("tick bob");

let failures: serde_json::Value =
serde_json::from_str(&bob.drain_failures().expect("bob failures"))
.expect("decode failures");
let failure_array = failures.as_array().expect("failure array");
assert_eq!(failure_array.len(), 1);
assert_eq!(failure_array[0]["op_type"], "sign");
assert_eq!(failure_array[0]["code"], "peer_rejected");
assert!(
failure_array[0]["message"]
.as_str()
.expect("failure message")
.to_ascii_lowercase()
.contains("nonce unavailable")
);
}

#[test]
fn restore_runtime_round_trip_preserves_runtime_metadata_and_status() {
let bundle =
Expand Down Expand Up @@ -1991,6 +2089,15 @@ mod tests {
serde_json::from_str(&bob.drain_outbound_events().expect("bob outbound"))
.expect("decode bob outbound");
assert_eq!(bob_outbound.len(), 1);
let bob_completions: serde_json::Value =
serde_json::from_str(&bob.drain_completions().expect("bob completions"))
.expect("decode bob completions");
let bob_completion_array = bob_completions.as_array().expect("completion array");
assert_eq!(bob_completion_array.len(), 1);
assert_eq!(
bob_completion_array[0]["PingServed"]["peer_pubkey32_hex"],
bob_bootstrap.peers[0]
);

alice
.handle_inbound_event(serde_json::to_string(&bob_outbound[0]).expect("encode reply"))
Expand Down Expand Up @@ -2134,6 +2241,18 @@ impl From<CompletedOperation> for CompletedOperationJson {
shared_secret_hex32: hex::encode(shared_secret),
},
CompletedOperation::Ping { request_id, peer } => Self::Ping { request_id, peer },
CompletedOperation::PingServed { request_id, peer } => Self::PingServed {
request_id,
peer_pubkey32_hex: peer,
},
CompletedOperation::EcdhServed { request_id, peer } => Self::EcdhServed {
request_id,
peer_pubkey32_hex: peer,
},
CompletedOperation::SignServed { request_id, peer } => Self::SignServed {
request_id,
peer_pubkey32_hex: peer,
},
CompletedOperation::Onboard {
request_id,
group_member_count,
Expand Down
29 changes: 29 additions & 0 deletions crates/bifrost-codec/src/wire.rs
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,8 @@ pub struct PingPayloadWire {
pub version: u16,
pub advertised_nonces: Vec<DerivedPublicNonceWire>,
pub held_peer_nonce_codes: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub recognized_peer_nonce_codes: Option<Vec<String>>,
pub policy_profile: Option<PeerScopedPolicyProfileWire>,
/// Hex of the sender's nonce-pool generation. Empty/absent on legacy peers.
#[serde(default)]
Expand Down Expand Up @@ -637,6 +639,15 @@ impl TryFrom<PingPayloadWire> for PingPayload {
"ping held peer nonce codes exceed max size",
));
}
if value
.recognized_peer_nonce_codes
.as_ref()
.is_some_and(|codes| codes.len() > MAX_NONCE_PACKAGE)
{
return Err(crate::error::CodecError::InvalidPayload(
"ping recognized peer nonce codes exceed max size",
));
}
let advertised_nonces = value
.advertised_nonces
.into_iter()
Expand All @@ -647,6 +658,15 @@ impl TryFrom<PingPayloadWire> for PingPayload {
.into_iter()
.map(|code| hexbytes::decode(&code))
.collect::<Result<Vec<_>, _>>()?;
let recognized_peer_nonce_codes = value
.recognized_peer_nonce_codes
.map(|codes| {
codes
.into_iter()
.map(|code| hexbytes::decode(&code))
.collect::<Result<Vec<_>, _>>()
})
.transpose()?;

let nonce_pool_generation = if value.nonce_pool_generation.is_empty() {
bifrost_core::nonce::UNKNOWN_POOL_GENERATION
Expand All @@ -658,6 +678,7 @@ impl TryFrom<PingPayloadWire> for PingPayload {
version: value.version,
advertised_nonces,
held_peer_nonce_codes,
recognized_peer_nonce_codes,
policy_profile: value.policy_profile.map(TryInto::try_into).transpose()?,
nonce_pool_generation,
})
Expand All @@ -678,6 +699,12 @@ impl From<PingPayload> for PingPayloadWire {
.into_iter()
.map(|code| hexbytes::encode(&code))
.collect(),
recognized_peer_nonce_codes: value.recognized_peer_nonce_codes.map(|codes| {
codes
.into_iter()
.map(|code| hexbytes::encode(&code))
.collect()
}),
policy_profile: value.policy_profile.map(Into::into),
nonce_pool_generation: if value.nonce_pool_generation
== bifrost_core::nonce::UNKNOWN_POOL_GENERATION
Expand Down Expand Up @@ -1033,6 +1060,7 @@ mod tests {
code: [3u8; 32],
}],
held_peer_nonce_codes: vec![[4u8; 32], [5u8; 32]],
recognized_peer_nonce_codes: Some(vec![[6u8; 32]]),
policy_profile: None,
nonce_pool_generation: [9u8; 32],
};
Expand All @@ -1047,6 +1075,7 @@ mod tests {
version: 1,
advertised_nonces: Vec::new(),
held_peer_nonce_codes: Vec::new(),
recognized_peer_nonce_codes: None,
policy_profile: None,
nonce_pool_generation: String::new(),
};
Expand Down
5 changes: 5 additions & 0 deletions crates/bifrost-codec/tests/wire_fuzz.rs
Original file line number Diff line number Diff line change
Expand Up @@ -235,6 +235,11 @@ fn gen_ping(rng: &mut Rng) -> PingPayloadWire {
version: if rng.bool() { 2 } else { rng.u16() },
advertised_nonces: (0..list_len(rng).min(6)).map(|_| gen_nonce(rng)).collect(),
held_peer_nonce_codes: (0..list_len(rng).min(6)).map(|_| field(rng, 32)).collect(),
recognized_peer_nonce_codes: if rng.bool() {
None
} else {
Some((0..list_len(rng).min(6)).map(|_| field(rng, 32)).collect())
},
policy_profile: if rng.bool() {
None
} else {
Expand Down
74 changes: 74 additions & 0 deletions crates/bifrost-core/src/nonce.rs
Original file line number Diff line number Diff line change
Expand Up @@ -236,6 +236,17 @@ impl NoncePool {
}
}

pub fn retain_incoming_codes(&mut self, peer_idx: u16, codes: &[Bytes32]) {
let Some(map) = self.incoming.get_mut(&peer_idx) else {
return;
};
let retain = codes.iter().copied().collect::<HashSet<_>>();
map.retain(|code, _| retain.contains(code));
if let Some(order) = self.incoming_order.get_mut(&peer_idx) {
order.retain(|code| map.contains_key(code));
}
}

pub fn consume_incoming(&mut self, peer_idx: u16) -> Option<MemberPublicNonce> {
let map = self.incoming.get_mut(&peer_idx)?;
let order = self.incoming_order.get_mut(&peer_idx)?;
Expand All @@ -252,6 +263,22 @@ impl NoncePool {
None
}

pub fn consume_latest_incoming(&mut self, peer_idx: u16) -> Option<MemberPublicNonce> {
let map = self.incoming.get_mut(&peer_idx)?;
let order = self.incoming_order.get_mut(&peer_idx)?;
while let Some(code) = order.pop_back() {
if let Some(nonce) = map.remove(&code) {
return Some(MemberPublicNonce {
idx: peer_idx,
binder_pn: nonce.binder_pn,
hidden_pn: nonce.hidden_pn,
code: nonce.code,
});
}
}
None
}

pub fn take_outgoing_signing_nonces(
&mut self,
peer_idx: u16,
Expand Down Expand Up @@ -532,4 +559,51 @@ mod tests {
assert_eq!(consumed_first.code, first.code);
assert_eq!(consumed_second.code, second.code);
}

#[test]
fn retain_incoming_codes_prunes_stale_entries_and_order() {
let mut pool = NoncePool::new(1, NoncePoolConfig::default());
pool.init_peer(2);

let stale = DerivedPublicNonce {
binder_pn: [2u8; 33],
hidden_pn: [3u8; 33],
code: [10u8; 32],
};
let current = DerivedPublicNonce {
binder_pn: [4u8; 33],
hidden_pn: [5u8; 33],
code: [11u8; 32],
};
pool.store_incoming(2, vec![stale, current.clone()]);
pool.retain_incoming_codes(2, &[current.code]);

let consumed = pool.consume_incoming(2).expect("retained nonce");
assert_eq!(consumed.code, current.code);
assert!(pool.consume_incoming(2).is_none());
}

#[test]
fn consume_latest_incoming_prefers_newly_stored_nonces() {
let mut pool = NoncePool::new(1, NoncePoolConfig::default());
pool.init_peer(2);

let old = DerivedPublicNonce {
binder_pn: [2u8; 33],
hidden_pn: [3u8; 33],
code: [10u8; 32],
};
let new = DerivedPublicNonce {
binder_pn: [4u8; 33],
hidden_pn: [5u8; 33],
code: [11u8; 32],
};
pool.store_incoming(2, vec![old.clone()]);
pool.store_incoming(2, vec![new.clone()]);

let consumed_new = pool.consume_latest_incoming(2).expect("latest nonce");
let consumed_old = pool.consume_latest_incoming(2).expect("old nonce");
assert_eq!(consumed_new.code, new.code);
assert_eq!(consumed_old.code, old.code);
}
}
Loading
Loading