Skip to content

Commit 4c8ec3a

Browse files
authored
Merge pull request #41 from Foundation-Devices/prevent-replay-attacks
SFT-5885: Prevent replay attacks
2 parents c1d0291 + 3e848ca commit 4c8ec3a

File tree

5 files changed

+244
-5
lines changed

5 files changed

+244
-5
lines changed

Cargo.lock

Lines changed: 2 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

api/Cargo.toml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,8 @@ flutter_rust_bridge = "=2.11.1"
3333
quantum-link-macros = { workspace = true }
3434
gstp = "0.5.0"
3535
bc-components = "0.17.0"
36+
dcbor = "0.16.5"
37+
chrono = "0.4"
3638

3739
[lints.rust]
3840
unexpected_cfgs = { level = "warn", check-cfg = ['cfg(frb_expand)'] }

api/src/api/quantum_link.rs

Lines changed: 234 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,71 @@
1+
use std::time::Duration;
2+
13
use anyhow::bail;
24
use bc_components::{EncapsulationScheme, PrivateKeys, PublicKeys, SignatureScheme, ARID};
35
use bc_envelope::{
46
prelude::{CBORCase, CBOR},
57
Envelope, EventBehavior, Expression, ExpressionBehavior, Function,
68
};
79
use bc_xid::XIDDocument;
10+
use chrono::{DateTime, Utc};
11+
use dcbor::Date;
812
use flutter_rust_bridge::frb;
913
use gstp::{SealedEvent, SealedEventBehavior};
1014

1115
use crate::message::{EnvoyMessage, PassportMessage};
1216

1317
pub const QUANTUM_LINK: Function = Function::new_static_named("quantumLink");
18+
pub const EXPIRATION_DURATION: Duration = Duration::from_secs(60);
19+
20+
/// Storage for tracking received ARIDs to prevent replay attacks
21+
#[derive(Debug, Default, Clone)]
22+
pub struct ARIDCache {
23+
cache: Vec<(ARID, DateTime<Utc>)>,
24+
}
25+
26+
impl ARIDCache {
27+
pub fn new() -> Self {
28+
Self { cache: Vec::new() }
29+
}
30+
31+
/// Check if ARID has been seen before and store it if not
32+
/// Returns true if this is a replay attack (ARID already exists)
33+
pub fn check_and_store(
34+
&mut self,
35+
arid: &ARID,
36+
sent_at: DateTime<Utc>,
37+
now: DateTime<Utc>,
38+
) -> bool {
39+
// Clean up expired entries first
40+
self.cache
41+
.retain(|(_, date)| now < (*date + EXPIRATION_DURATION));
42+
43+
// Check if ARID already exists (replay attack)
44+
if self.cache.iter().any(|(id, _)| id == arid) {
45+
return true; // Replay attack detected
46+
}
47+
48+
if now < (sent_at + EXPIRATION_DURATION) {
49+
self.cache.push((arid.clone(), sent_at));
50+
}
51+
false // Not a replay attack
52+
}
53+
54+
/// Get the number of stored ARIDs
55+
pub fn len(&self) -> usize {
56+
self.cache.len()
57+
}
58+
59+
pub fn is_empty(&self) -> bool {
60+
self.cache.len() == 0
61+
}
62+
63+
/// Clear all stored ARIDs
64+
pub fn clear(&mut self) {
65+
self.cache.clear();
66+
}
67+
}
68+
1469
pub trait QuantumLink<C>: minicbor::Encode<C> {
1570
fn encode(&self) -> Expression
1671
where
@@ -46,11 +101,14 @@ pub trait QuantumLink<C>: minicbor::Encode<C> {
46101
where
47102
Self: minicbor::Encode<()>,
48103
{
104+
let valid_until = Date::with_duration_from_now(EXPIRATION_DURATION);
105+
49106
let event: SealedEvent<Expression> =
50-
SealedEvent::new(QuantumLink::encode(self), ARID::new(), sender.xid_document);
107+
SealedEvent::new(QuantumLink::encode(self), ARID::new(), sender.xid_document)
108+
.with_date(&valid_until);
51109
event
52110
.to_envelope(
53-
None,
111+
Some(&valid_until),
54112
Some(&sender.private_keys.unwrap()),
55113
Some(&recipient.xid_document),
56114
)
@@ -65,7 +123,30 @@ pub trait QuantumLink<C>: minicbor::Encode<C> {
65123
Self: for<'a> minicbor::Decode<'a, ()>,
66124
{
67125
let event: SealedEvent<Expression> =
68-
SealedEvent::try_from_envelope(envelope, None, None, private_keys)?;
126+
SealedEvent::try_from_envelope(envelope, None, Some(&Date::now()), private_keys)?;
127+
let expression = event.content().clone();
128+
Ok((expression, event.sender().clone()))
129+
}
130+
131+
fn unseal_with_replay_check(
132+
envelope: &Envelope,
133+
private_keys: &PrivateKeys,
134+
arid_cache: &mut ARIDCache,
135+
) -> anyhow::Result<(Expression, XIDDocument)>
136+
where
137+
Self: for<'a> minicbor::Decode<'a, ()>,
138+
{
139+
let now = Utc::now();
140+
let event: SealedEvent<Expression> =
141+
SealedEvent::try_from_envelope(envelope, None, Some(&Date::from(now)), private_keys)?;
142+
143+
// Check for replay attack
144+
let arid = event.id();
145+
let event_date = event.date().unwrap().datetime();
146+
if arid_cache.check_and_store(arid, event_date, now) {
147+
bail!("Replay attack detected: ARID has been seen before");
148+
}
149+
69150
let expression = event.content().clone();
70151
Ok((expression, event.sender().clone()))
71152
}
@@ -78,13 +159,33 @@ pub trait QuantumLink<C>: minicbor::Encode<C> {
78159
Ok((PassportMessage::decode(&expression)?, sender))
79160
}
80161

162+
fn unseal_passport_message_with_replay_check(
163+
envelope: &Envelope,
164+
private_keys: &PrivateKeys,
165+
arid_cache: &mut ARIDCache,
166+
) -> anyhow::Result<(PassportMessage, XIDDocument)> {
167+
let (expression, sender) =
168+
PassportMessage::unseal_with_replay_check(envelope, private_keys, arid_cache)?;
169+
Ok((PassportMessage::decode(&expression)?, sender))
170+
}
171+
81172
fn unseal_envoy_message(
82173
envelope: &Envelope,
83174
private_keys: &PrivateKeys,
84175
) -> anyhow::Result<(EnvoyMessage, XIDDocument)> {
85176
let (expression, sender) = EnvoyMessage::unseal(envelope, private_keys)?;
86177
Ok((EnvoyMessage::decode(&expression)?, sender))
87178
}
179+
180+
fn unseal_envoy_message_with_replay_check(
181+
envelope: &Envelope,
182+
private_keys: &PrivateKeys,
183+
arid_cache: &mut ARIDCache,
184+
) -> anyhow::Result<(EnvoyMessage, XIDDocument)> {
185+
let (expression, sender) =
186+
EnvoyMessage::unseal_with_replay_check(envelope, private_keys, arid_cache)?;
187+
Ok((EnvoyMessage::decode(&expression)?, sender))
188+
}
88189
}
89190

90191
#[derive(Debug, Clone)]
@@ -149,7 +250,7 @@ mod tests {
149250
api::{message::QuantumLinkMessage, quantum_link::QuantumLink},
150251
fx::ExchangeRate,
151252
message::EnvoyMessage,
152-
quantum_link::QuantumLinkIdentity,
253+
quantum_link::{ARIDCache, QuantumLinkIdentity},
153254
};
154255

155256
#[test]
@@ -212,4 +313,133 @@ mod tests {
212313
deserialized_identity.private_keys.unwrap()
213314
);
214315
}
316+
317+
#[test]
318+
fn test_replay_attack_prevention() {
319+
let envoy = QuantumLinkIdentity::generate();
320+
let passport = QuantumLinkIdentity::generate();
321+
let mut arid_cache = ARIDCache::new();
322+
323+
let fx_rate = ExchangeRate::new("USD", 0.85);
324+
let original_message = EnvoyMessage {
325+
message: QuantumLinkMessage::ExchangeRate(fx_rate.clone()),
326+
timestamp: 123456,
327+
};
328+
329+
// Seal the message
330+
let envelope = QuantumLink::seal(&original_message, envoy.clone(), passport.clone());
331+
332+
// First unseal should succeed
333+
let result1 = EnvoyMessage::unseal_envoy_message_with_replay_check(
334+
&envelope,
335+
&passport.private_keys.clone().unwrap(),
336+
&mut arid_cache,
337+
);
338+
assert!(result1.is_ok());
339+
340+
// Second unseal of the same message should fail (replay attack)
341+
let result2 = EnvoyMessage::unseal_envoy_message_with_replay_check(
342+
&envelope,
343+
&passport.private_keys.unwrap(),
344+
&mut arid_cache,
345+
);
346+
assert!(result2.is_err());
347+
assert!(result2
348+
.unwrap_err()
349+
.to_string()
350+
.contains("Replay attack detected"));
351+
}
352+
353+
#[test]
354+
fn test_arid_cache_cleanup() {
355+
let mut arid_cache = ARIDCache::new();
356+
let envoy = QuantumLinkIdentity::generate();
357+
let passport = QuantumLinkIdentity::generate();
358+
359+
// Create and seal multiple messages
360+
let fx_rate = ExchangeRate::new("USD", 0.85);
361+
let message1 = EnvoyMessage {
362+
message: QuantumLinkMessage::ExchangeRate(fx_rate.clone()),
363+
timestamp: 123456,
364+
};
365+
let message2 = EnvoyMessage {
366+
message: QuantumLinkMessage::ExchangeRate(fx_rate.clone()),
367+
timestamp: 123457,
368+
};
369+
370+
let envelope1 = QuantumLink::seal(&message1, envoy.clone(), passport.clone());
371+
let envelope2 = QuantumLink::seal(&message2, envoy.clone(), passport.clone());
372+
373+
// Unseal both messages
374+
let _result1 = EnvoyMessage::unseal_envoy_message_with_replay_check(
375+
&envelope1,
376+
&passport.private_keys.clone().unwrap(),
377+
&mut arid_cache,
378+
)
379+
.unwrap();
380+
let _result2 = EnvoyMessage::unseal_envoy_message_with_replay_check(
381+
&envelope2,
382+
&passport.private_keys.unwrap(),
383+
&mut arid_cache,
384+
)
385+
.unwrap();
386+
387+
// Should have 2 ARIDs stored
388+
assert_eq!(arid_cache.len(), 2);
389+
390+
// Clear cache manually to test cleanup
391+
arid_cache.clear();
392+
assert_eq!(arid_cache.len(), 0);
393+
}
394+
}
395+
396+
#[test]
397+
fn test_time_based_eviction() {
398+
let mut cache = ARIDCache::new();
399+
let arid1 = ARID::new();
400+
let arid2 = ARID::new();
401+
402+
let expiration = chrono::Duration::from_std(EXPIRATION_DURATION).unwrap();
403+
let start = chrono::Utc::now();
404+
405+
cache.check_and_store(&arid1, start, start);
406+
assert_eq!(cache.len(), 1);
407+
408+
// evict the old time
409+
// evict the old time by advancing 'now' past expiration
410+
let future = start + expiration + chrono::Duration::seconds(1);
411+
cache.check_and_store(&arid2, future, future);
412+
assert_eq!(cache.len(), 1);
413+
assert!(!cache.cache.iter().any(|(id, _)| id == &arid1));
414+
}
415+
416+
#[test]
417+
fn test_replay_check() {
418+
use crate::{fx::ExchangeRate, message::QuantumLinkMessage};
419+
420+
let mut arid_cache = ARIDCache::new();
421+
let envoy = QuantumLinkIdentity::generate();
422+
let passport = QuantumLinkIdentity::generate();
423+
424+
let fx_rate = ExchangeRate::new("USD", 0.85);
425+
let message = EnvoyMessage {
426+
message: QuantumLinkMessage::ExchangeRate(fx_rate),
427+
timestamp: 123456,
428+
};
429+
430+
let envelope = QuantumLink::seal(&message, envoy.clone(), passport.clone());
431+
432+
let result1 = EnvoyMessage::unseal_envoy_message_with_replay_check(
433+
&envelope,
434+
&passport.private_keys.clone().unwrap(),
435+
&mut arid_cache,
436+
);
437+
assert!(result1.is_ok());
438+
439+
let result2 = EnvoyMessage::unseal_envoy_message_with_replay_check(
440+
&envelope,
441+
&passport.private_keys.unwrap(),
442+
&mut arid_cache,
443+
);
444+
assert!(result2.is_err());
215445
}

flake.nix

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,11 @@
3131
};
3232
in {
3333
default = pkgs.mkShellNoCC {
34-
packages = [toolchain];
34+
packages = with pkgs;
35+
[gcc]
36+
++ [
37+
toolchain
38+
];
3539
};
3640
}
3741
);

rust-toolchain.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,3 +3,4 @@
33

44
[toolchain]
55
channel = "nightly-2025-06-24"
6+
components = ["rust-analyzer", "rust-src"]

0 commit comments

Comments
 (0)