Skip to main content

axess_core/device/storage/
sql_common.rs

1//! Shared logic for SQL-backed device stores (SQLite, PostgreSQL).
2//!
3//! Mirrors the shape of
4//! [`session::storage::session_codec`](crate::session::storage::session_codec):
5//! a small codec that handles the bindings-blob serialisation + optional
6//! AES-256-GCM envelope encryption, and a shared error type so each
7//! backend doesn't reinvent the failure surface.
8//!
9//! # What's encrypted, what isn't
10//!
11//! Only the **bindings blob** runs through the optional codec. The
12//! structured columns (tenant_id, id, user_id, trust_level,
13//! fingerprint_hash, first_seen_at, last_seen_at, revoked_at) stay
14//! plaintext because every one of them is either a tenant-scoped
15//! opaque id, a keyed HMAC, or a timestamp the indexes need to be
16//! able to range-scan. Encrypting the bindings blob covers the
17//! material that *could* leak something operationally interesting
18//! (refresh-token family identifiers, WebAuthn credential references)
19//! without giving up the tenant-scoped query primitives that drive
20//! the rest of the device subsystem.
21
22use crate::device::types::DeviceBinding;
23use crate::session::crypto::{CryptoError, SessionCrypto};
24use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64};
25
26/// Codec for the bindings column on a device row.
27///
28/// Serialises `Vec<DeviceBinding>` with `rmp-serde` (MessagePack) and
29/// optionally encrypts with AES-256-GCM. The encrypted output is then
30/// base64-encoded so the column stays TEXT: same shape as the
31/// session store, intentionally.
32///
33/// `SessionCrypto` is reused as a *generic* AES-256-GCM envelope
34/// primitive. The "session" prefix in its name is historical; the
35/// implementation has no session-specific behaviour. Renaming to
36/// `EnvelopeCrypto` is a separate refactor (would touch every
37/// session-store call site).
38#[derive(Clone)]
39pub(crate) struct BindingsCodec {
40    crypto: Option<SessionCrypto>,
41}
42
43impl BindingsCodec {
44    pub fn encrypted(crypto: SessionCrypto) -> Self {
45        Self {
46            crypto: Some(crypto),
47        }
48    }
49
50    pub fn plaintext() -> Self {
51        Self { crypto: None }
52    }
53
54    /// Serialise (and optionally encrypt) the bindings vector.
55    pub fn encode(&self, bindings: &[DeviceBinding]) -> Result<String, SqlDeviceStoreError> {
56        let bytes = rmp_serde::to_vec_named(bindings)?;
57        let payload = match &self.crypto {
58            Some(crypto) => crypto.encrypt(&bytes)?,
59            None => bytes,
60        };
61        Ok(BASE64.encode(payload))
62    }
63
64    /// Deserialise (and optionally decrypt) the bindings vector.
65    /// Empty input is treated as `Vec::new()` so legacy NULL columns
66    /// or rows written before the bindings column existed still load.
67    pub fn decode(&self, stored: &str) -> Result<Vec<DeviceBinding>, SqlDeviceStoreError> {
68        if stored.is_empty() {
69            return Ok(Vec::new());
70        }
71        let payload = BASE64
72            .decode(stored)
73            .map_err(|_| SqlDeviceStoreError::Crypto(CryptoError))?;
74        let plaintext = match &self.crypto {
75            Some(crypto) => crypto.decrypt(&payload)?,
76            None => payload,
77        };
78        Ok(rmp_serde::from_slice(&plaintext)?)
79    }
80}
81
82/// Wire-form trust-level identifiers persisted in the `trust_level`
83/// column. Stable across schema changes: bump the schema version
84/// before changing these.
85pub(crate) mod trust_level_codec {
86    use crate::device::types::DeviceTrustLevel;
87
88    pub fn to_str(level: DeviceTrustLevel) -> &'static str {
89        match level {
90            DeviceTrustLevel::Unknown => "Unknown",
91            DeviceTrustLevel::Seen => "Seen",
92            DeviceTrustLevel::Trusted => "Trusted",
93            DeviceTrustLevel::Revoked => "Revoked",
94        }
95    }
96
97    pub fn from_str(s: &str) -> Option<DeviceTrustLevel> {
98        match s {
99            "Unknown" => Some(DeviceTrustLevel::Unknown),
100            "Seen" => Some(DeviceTrustLevel::Seen),
101            "Trusted" => Some(DeviceTrustLevel::Trusted),
102            "Revoked" => Some(DeviceTrustLevel::Revoked),
103            _ => None,
104        }
105    }
106}
107
108/// Errors from SQL-backed device stores.
109///
110/// Same shape as [`SqlStoreError`](crate::session::storage::session_codec::SqlStoreError)
111/// Distinct type so the device-store error surface doesn't widen
112/// when the session-store one does (and vice versa).
113#[derive(Debug, thiserror::Error)]
114pub enum SqlDeviceStoreError {
115    /// Underlying database driver returned an error.
116    #[error("database error: {0}")]
117    Db(#[from] sqlx::Error),
118
119    /// MessagePack encoding of the bindings vector failed.
120    #[error("device-bindings MessagePack encoding failed: {0}")]
121    Encode(#[from] rmp_serde::encode::Error),
122
123    /// MessagePack decoding of a stored bindings blob failed.
124    #[error("device-bindings MessagePack decoding failed: {0}")]
125    Decode(#[from] rmp_serde::decode::Error),
126
127    /// AES-256-GCM encryption or decryption of the bindings blob failed.
128    #[error("encryption/decryption error: {0}")]
129    Crypto(#[from] CryptoError),
130
131    /// Stored row carried a trust-level string the codec doesn't
132    /// recognise. Indicates schema drift (a future trust level was
133    /// written by a newer process and an older one is now reading it).
134    #[error("unrecognised trust_level value: {0}")]
135    UnknownTrustLevel(String),
136
137    /// Stored row carried a value that couldn't be parsed into the
138    /// expected newtype (e.g. `tenant_id` / `device_id` validation
139    /// rejected the bytes). Indicates row corruption: the value was
140    /// either written by a buggy producer or hand-edited.
141    #[error("malformed stored value: {0}")]
142    MalformedRow(String),
143}
144
145#[cfg(test)]
146mod device_sql_common_tests {
147    use super::*;
148    use crate::device::types::{
149        AttestationClass, DeviceBinding, DeviceTrustLevel, FingerprintHash,
150    };
151    use chrono::Utc;
152
153    /// line 68: `decode -> Ok(vec![])` body replacement.
154    /// Discriminator: encode a non-empty bindings vector and confirm
155    /// decode round-trips the same content. Mutation returns an empty
156    /// Vec for any non-empty input.
157    #[test]
158    fn bindings_codec_round_trips_non_empty_vector() {
159        let codec = BindingsCodec::plaintext();
160        let now = Utc::now();
161        let bindings = vec![
162            DeviceBinding::Cookie {
163                token_hash: FingerprintHash::from_bytes([0xAA; 32]),
164                issued_at: now,
165                last_used_at: now,
166            },
167            DeviceBinding::WebAuthn {
168                credential_id: "cred-AX028".to_string(),
169                attestation_class: AttestationClass::None,
170                bound_at: now,
171                last_used_at: now,
172            },
173        ];
174        let encoded = codec.encode(&bindings).expect("encode");
175        let decoded = codec.decode(&encoded).expect("decode");
176        assert_eq!(
177            decoded.len(),
178            2,
179            "decode must round-trip 2 bindings, not return empty"
180        );
181        assert_eq!(decoded, bindings, "round-trip must preserve content");
182    }
183
184    /// line 89: `to_str` body replacement (`""`, `"xyzzy"`).
185    /// Pin every variant's wire-form value.
186    #[test]
187    fn trust_level_to_str_pins_variant_strings() {
188        assert_eq!(
189            trust_level_codec::to_str(DeviceTrustLevel::Unknown),
190            "Unknown"
191        );
192        assert_eq!(trust_level_codec::to_str(DeviceTrustLevel::Seen), "Seen");
193        assert_eq!(
194            trust_level_codec::to_str(DeviceTrustLevel::Trusted),
195            "Trusted"
196        );
197        assert_eq!(
198            trust_level_codec::to_str(DeviceTrustLevel::Revoked),
199            "Revoked"
200        );
201    }
202
203    /// line 98 + 99-102: `from_str` body replacement and arm
204    /// deletions. Each variant string must round-trip; an unknown
205    /// string must yield None.
206    #[test]
207    fn trust_level_from_str_round_trips_each_variant() {
208        assert_eq!(
209            trust_level_codec::from_str("Unknown"),
210            Some(DeviceTrustLevel::Unknown)
211        );
212        assert_eq!(
213            trust_level_codec::from_str("Seen"),
214            Some(DeviceTrustLevel::Seen)
215        );
216        assert_eq!(
217            trust_level_codec::from_str("Trusted"),
218            Some(DeviceTrustLevel::Trusted)
219        );
220        assert_eq!(
221            trust_level_codec::from_str("Revoked"),
222            Some(DeviceTrustLevel::Revoked)
223        );
224        assert_eq!(
225            trust_level_codec::from_str("not-a-level"),
226            None,
227            "unknown input must yield None, not Some(Default)"
228        );
229    }
230
231    /// Belt-and-suspenders: `to_str` and `from_str` form a
232    /// bijection on the four variants. Pins both directions in one go.
233    #[test]
234    fn trust_level_codec_is_a_bijection_on_known_variants() {
235        for level in [
236            DeviceTrustLevel::Unknown,
237            DeviceTrustLevel::Seen,
238            DeviceTrustLevel::Trusted,
239            DeviceTrustLevel::Revoked,
240        ] {
241            let s = trust_level_codec::to_str(level);
242            assert_eq!(
243                trust_level_codec::from_str(s),
244                Some(level),
245                "{level:?} must round-trip via to_str/from_str"
246            );
247        }
248    }
249}