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}