Skip to main content

freebird_verifier/
store.rs

1// SPDX-License-Identifier: Apache-2.0 OR MIT
2// Copyright 2024 The Carpocratian Church of Commonality and Equality, Inc.
3use anyhow::{Context, Result};
4use async_trait::async_trait;
5use redis::{aio::ConnectionLike, AsyncCommands, Script};
6use std::{
7    collections::HashMap,
8    sync::Arc,
9    time::{Duration, Instant},
10};
11use subtle::ConstantTimeEq;
12use tokio::sync::RwLock;
13use tracing::{debug, info, warn};
14
15//
16// ─── REDIS LUA SCRIPT ────────────────────────────────────────────────
17//   Atomic set-if-absent with expiry
18//
19const LUA_MARK_SPENT: &str = r#"
20  -- KEYS[1] = token_key, ARGV[1] = ttl (seconds)
21  if redis.call('SETNX', KEYS[1], '1') == 1 then
22    redis.call('EXPIRE', KEYS[1], ARGV[1])
23    return 1
24  else
25    return 0
26  end
27"#;
28
29//
30// ─── LOW-LEVEL REDIS UTILITIES ───────────────────────────────────────
31//
32pub async fn has_been_spent<C: ConnectionLike + Send>(
33    conn: &mut C,
34    token_key: &str,
35) -> Result<bool> {
36    let v: Option<String> = conn.get(token_key).await?;
37    Ok(v.is_some())
38}
39
40pub async fn mark_spent_atomic<C: ConnectionLike + Send>(
41    conn: &mut C,
42    token_key: &str,
43    ttl_seconds: usize,
44) -> Result<bool> {
45    let script = Script::new(LUA_MARK_SPENT);
46    let res: i32 = script
47        .key(token_key)
48        .arg(ttl_seconds)
49        .invoke_async(conn)
50        .await
51        .context("invoke redis lua")?;
52    Ok(res == 1)
53}
54
55//
56// ─── GENERIC SPEND STORE TRAIT ──────────────────────────────────────
57//
58#[async_trait]
59pub trait SpendStore: Send + Sync {
60    /// Attempts to mark a spend handle as used.
61    /// Returns true if this is the first time (fresh),
62    /// false if it was already present (replay).
63    async fn mark_spent(&self, key: &str, ttl: Duration) -> Result<bool>;
64}
65
66//
67// ─── CONSTANT-TIME STRING COMPARISON ────────────────────────────────
68//
69/// Constant-time comparison for nullifier keys to prevent timing attacks
70fn constant_time_eq(a: &str, b: &str) -> bool {
71    if a.len() != b.len() {
72        return false;
73    }
74    bool::from(a.as_bytes().ct_eq(b.as_bytes()))
75}
76
77//
78// ─── IN-MEMORY BACKEND ───────────────────────────────────────────────
79//
80#[derive(Default)]
81pub struct InMemoryStore {
82    map: Arc<RwLock<HashMap<String, Instant>>>,
83}
84
85#[async_trait]
86impl SpendStore for InMemoryStore {
87    async fn mark_spent(&self, key: &str, ttl: Duration) -> Result<bool> {
88        let mut map = self.map.write().await;
89        let now = Instant::now();
90
91        // purge expired
92        map.retain(|_, &mut exp| exp > now);
93
94        // Use constant-time comparison to check for key existence
95        // This prevents timing attacks on nullifier lookup
96        let mut found = false;
97        for stored_key in map.keys() {
98            if constant_time_eq(stored_key, key) {
99                found = true;
100                break;
101            }
102        }
103
104        if found {
105            debug!(%key, "replay detected (in-memory)");
106            Ok(false)
107        } else {
108            map.insert(key.to_owned(), now + ttl);
109            debug!(%key, ttl=?ttl, "marked spent (in-memory)");
110            Ok(true)
111        }
112    }
113}
114
115//
116// ─── REDIS BACKEND ──────────────────────────────────────────────────
117//
118pub struct RedisStore {
119    client: redis::Client,
120}
121
122impl RedisStore {
123    pub fn new(url: &str) -> Result<Self> {
124        let client =
125            redis::Client::open(url).with_context(|| format!("connect redis @ {}", url))?;
126        Ok(Self { client })
127    }
128
129    async fn get_conn(&self) -> Result<redis::aio::Connection> {
130        let mut backoff_ms = 200u64;
131        for attempt in 1..=3 {
132            match self.client.get_async_connection().await {
133                Ok(conn) => return Ok(conn),
134                Err(e) if attempt < 3 => {
135                    warn!(
136                        attempt,
137                        "redis connect failed: {e}; retrying in {backoff_ms}ms"
138                    );
139                    tokio::time::sleep(Duration::from_millis(backoff_ms)).await;
140                    backoff_ms *= 2;
141                }
142                Err(e) => return Err(e.into()),
143            }
144        }
145        unreachable!()
146    }
147}
148
149#[async_trait]
150impl SpendStore for RedisStore {
151    async fn mark_spent(&self, key: &str, ttl: Duration) -> Result<bool> {
152        let ttl_secs = ttl.as_secs().max(1) as usize;
153        let mut conn = self.get_conn().await?;
154        let fresh = mark_spent_atomic(&mut conn, key, ttl_secs).await?;
155
156        if fresh {
157            info!(%key, ttl = %ttl_secs, "marked spent (redis)");
158        } else {
159            warn!(%key, "replay detected (redis)");
160        }
161        Ok(fresh)
162    }
163}
164
165//
166// ─── FACTORY FUNCTION ────────────────────────────────────────────────
167//
168pub enum StoreBackend {
169    InMemory,
170    Redis(String),
171}
172
173impl StoreBackend {
174    pub async fn build(self) -> Arc<dyn SpendStore> {
175        match self {
176            StoreBackend::InMemory => {
177                info!("using InMemory spend store");
178                Arc::new(InMemoryStore::default())
179            }
180            StoreBackend::Redis(url) => {
181                info!(%url, "using Redis spend store");
182                Arc::new(RedisStore::new(&url).expect("connect redis"))
183            }
184        }
185    }
186}