1use ff_core::engine_error::EngineError;
33use hmac::{Hmac, Mac};
34use sha2::Sha256;
35use sqlx::PgPool;
36
37use crate::error::map_sqlx_error;
38
39pub const SERIALIZABLE_RETRY_BUDGET: usize = 3;
44
45pub fn is_retryable_serialization(err: &sqlx::Error) -> bool {
49 if let Some(db) = err.as_database_error()
50 && let Some(code) = db.code()
51 {
52 matches!(code.as_ref(), "40001" | "40P01")
53 } else {
54 false
55 }
56}
57
58pub fn hmac_sign(secret: &[u8], kid: &str, message: &[u8]) -> String {
64 let mut mac = <Hmac<Sha256> as Mac>::new_from_slice(secret)
65 .expect("HMAC-SHA256 accepts any key length");
66 mac.update(kid.as_bytes());
67 mac.update(b":");
68 mac.update(message);
69 let out = mac.finalize().into_bytes();
70 format!("{kid}:{}", hex::encode(out))
71}
72
73pub fn hmac_verify(
77 secret: &[u8],
78 kid: &str,
79 message: &[u8],
80 token: &str,
81) -> Result<(), HmacVerifyError> {
82 let (tok_kid, tok_hex) =
83 token.split_once(':').ok_or(HmacVerifyError::Malformed)?;
84 if tok_kid != kid {
85 return Err(HmacVerifyError::WrongKid {
86 expected: kid.to_owned(),
87 actual: tok_kid.to_owned(),
88 });
89 }
90 let expected = hex::decode(tok_hex).map_err(|_| HmacVerifyError::Malformed)?;
91 let mut mac = <Hmac<Sha256> as Mac>::new_from_slice(secret)
92 .map_err(|_| HmacVerifyError::Malformed)?;
93 mac.update(kid.as_bytes());
94 mac.update(b":");
95 mac.update(message);
96 mac.verify_slice(&expected)
97 .map_err(|_| HmacVerifyError::SignatureMismatch)
98}
99
100#[derive(Debug, thiserror::Error)]
103pub enum HmacVerifyError {
104 #[error("token malformed; expected kid:hex shape")]
105 Malformed,
106 #[error("token kid mismatch; expected {expected}, got {actual}")]
107 WrongKid { expected: String, actual: String },
108 #[error("HMAC signature mismatch")]
109 SignatureMismatch,
110}
111
112pub async fn current_active_kid(
116 pool: &PgPool,
117) -> Result<Option<(String, Vec<u8>)>, EngineError> {
118 let row: Option<(String, Vec<u8>)> = sqlx::query_as(
119 "SELECT kid, secret FROM ff_waitpoint_hmac \
120 WHERE active = TRUE \
121 ORDER BY rotated_at_ms DESC LIMIT 1",
122 )
123 .fetch_optional(pool)
124 .await
125 .map_err(map_sqlx_error)?;
126 Ok(row)
127}
128
129pub async fn fetch_kid(pool: &PgPool, kid: &str) -> Result<Option<Vec<u8>>, EngineError> {
132 let row: Option<(Vec<u8>,)> = sqlx::query_as(
133 "SELECT secret FROM ff_waitpoint_hmac WHERE kid = $1",
134 )
135 .bind(kid)
136 .fetch_optional(pool)
137 .await
138 .map_err(map_sqlx_error)?;
139 Ok(row.map(|(s,)| s))
140}
141
142pub async fn rotate_waitpoint_hmac_secret_all_impl(
163 pool: &PgPool,
164 args: ff_core::contracts::RotateWaitpointHmacSecretAllArgs,
165 now_ms: i64,
166) -> Result<ff_core::contracts::RotateWaitpointHmacSecretAllResult, EngineError> {
167 use ff_core::contracts::{
168 RotateWaitpointHmacSecretAllEntry, RotateWaitpointHmacSecretAllResult,
169 RotateWaitpointHmacSecretOutcome,
170 };
171
172 let secret_bytes = hex::decode(&args.new_secret_hex).map_err(|_| {
175 EngineError::Validation {
176 kind: ff_core::engine_error::ValidationKind::InvalidInput,
177 detail: "new_secret_hex is not valid hex".into(),
178 }
179 })?;
180
181 let outcome_res: Result<RotateWaitpointHmacSecretOutcome, EngineError> = async {
182 let mut tx = pool.begin().await.map_err(map_sqlx_error)?;
183
184 let existing: Option<(Vec<u8>,)> = sqlx::query_as(
186 "SELECT secret FROM ff_waitpoint_hmac WHERE kid = $1",
187 )
188 .bind(&args.new_kid)
189 .fetch_optional(&mut *tx)
190 .await
191 .map_err(map_sqlx_error)?;
192 if let Some((prior,)) = existing {
193 if prior == secret_bytes {
194 tx.commit().await.map_err(map_sqlx_error)?;
195 return Ok(RotateWaitpointHmacSecretOutcome::Noop {
196 kid: args.new_kid.clone(),
197 });
198 }
199 tx.rollback().await.ok();
200 return Err(EngineError::Conflict(
201 ff_core::engine_error::ConflictKind::RotationConflict(format!(
202 "kid {} already installed with a different secret",
203 args.new_kid
204 )),
205 ));
206 }
207
208 let prior_active: Option<(String,)> = sqlx::query_as(
210 "SELECT kid FROM ff_waitpoint_hmac \
211 WHERE active = TRUE \
212 ORDER BY rotated_at_ms DESC LIMIT 1",
213 )
214 .fetch_optional(&mut *tx)
215 .await
216 .map_err(map_sqlx_error)?;
217
218 let _ = args.grace_ms;
223
224 sqlx::query("UPDATE ff_waitpoint_hmac SET active = FALSE WHERE active = TRUE")
225 .execute(&mut *tx)
226 .await
227 .map_err(map_sqlx_error)?;
228
229 sqlx::query(
230 "INSERT INTO ff_waitpoint_hmac (kid, secret, rotated_at_ms, active) \
231 VALUES ($1, $2, $3, TRUE)",
232 )
233 .bind(&args.new_kid)
234 .bind(&secret_bytes)
235 .bind(now_ms)
236 .execute(&mut *tx)
237 .await
238 .map_err(map_sqlx_error)?;
239
240 tx.commit().await.map_err(map_sqlx_error)?;
241
242 Ok(RotateWaitpointHmacSecretOutcome::Rotated {
243 previous_kid: prior_active.map(|(k,)| k),
244 new_kid: args.new_kid.clone(),
245 gc_count: 0,
246 })
247 }
248 .await;
249
250 Ok(RotateWaitpointHmacSecretAllResult::new(vec![
251 RotateWaitpointHmacSecretAllEntry::new(0, outcome_res),
252 ]))
253}
254
255pub async fn seed_waitpoint_hmac_secret_impl(
270 pool: &PgPool,
271 args: ff_core::contracts::SeedWaitpointHmacSecretArgs,
272 now_ms: i64,
273) -> Result<ff_core::contracts::SeedOutcome, EngineError> {
274 use ff_core::contracts::SeedOutcome;
275
276 if args.secret_hex.len() != 64 || !args.secret_hex.chars().all(|c| c.is_ascii_hexdigit()) {
277 return Err(EngineError::Validation {
278 kind: ff_core::engine_error::ValidationKind::InvalidInput,
279 detail: "secret_hex must be 64 hex characters (256-bit secret)".into(),
280 });
281 }
282 if args.kid.is_empty() {
283 return Err(EngineError::Validation {
284 kind: ff_core::engine_error::ValidationKind::InvalidInput,
285 detail: "kid must be non-empty".into(),
286 });
287 }
288 let secret_bytes = hex::decode(&args.secret_hex).map_err(|_| EngineError::Validation {
289 kind: ff_core::engine_error::ValidationKind::InvalidInput,
290 detail: "secret_hex is not valid hex".into(),
291 })?;
292
293 let mut tx = pool.begin().await.map_err(map_sqlx_error)?;
294
295 let existing: Option<(Vec<u8>,)> =
296 sqlx::query_as("SELECT secret FROM ff_waitpoint_hmac WHERE kid = $1")
297 .bind(&args.kid)
298 .fetch_optional(&mut *tx)
299 .await
300 .map_err(map_sqlx_error)?;
301 if let Some((prior,)) = existing {
302 tx.commit().await.map_err(map_sqlx_error)?;
303 return Ok(SeedOutcome::AlreadySeeded {
304 kid: args.kid,
305 same_secret: prior == secret_bytes,
306 });
307 }
308
309 let active: Option<(String,)> = sqlx::query_as(
310 "SELECT kid FROM ff_waitpoint_hmac WHERE active = TRUE \
311 ORDER BY rotated_at_ms DESC LIMIT 1",
312 )
313 .fetch_optional(&mut *tx)
314 .await
315 .map_err(map_sqlx_error)?;
316 if let Some((active_kid,)) = active {
317 tx.rollback().await.ok();
318 return Err(EngineError::Validation {
319 kind: ff_core::engine_error::ValidationKind::InvalidInput,
320 detail: format!(
321 "seed_waitpoint_hmac_secret: a different kid {active_kid:?} is already active; \
322 use rotate_waitpoint_hmac_secret_all to change kid"
323 ),
324 });
325 }
326
327 sqlx::query(
328 "INSERT INTO ff_waitpoint_hmac (kid, secret, rotated_at_ms, active) \
329 VALUES ($1, $2, $3, TRUE)",
330 )
331 .bind(&args.kid)
332 .bind(&secret_bytes)
333 .bind(now_ms)
334 .execute(&mut *tx)
335 .await
336 .map_err(map_sqlx_error)?;
337
338 tx.commit().await.map_err(map_sqlx_error)?;
339 Ok(SeedOutcome::Seeded { kid: args.kid })
340}
341
342#[cfg(test)]
343mod hmac_tests {
344 use super::*;
345
346 #[test]
347 fn sign_then_verify_round_trip() {
348 let secret = b"super-secret-key";
349 let tok = hmac_sign(secret, "kid1", b"exec-id:wp-id");
350 assert!(tok.starts_with("kid1:"));
351 hmac_verify(secret, "kid1", b"exec-id:wp-id", &tok).expect("verify ok");
352 }
353
354 #[test]
355 fn verify_rejects_tampered_message() {
356 let secret = b"s";
357 let tok = hmac_sign(secret, "k", b"msg");
358 let err = hmac_verify(secret, "k", b"tampered", &tok).unwrap_err();
359 assert!(matches!(err, HmacVerifyError::SignatureMismatch));
360 }
361
362 #[test]
363 fn verify_rejects_wrong_kid() {
364 let secret = b"s";
365 let tok = hmac_sign(secret, "k1", b"msg");
366 let err = hmac_verify(secret, "k2", b"msg", &tok).unwrap_err();
367 assert!(matches!(err, HmacVerifyError::WrongKid { .. }));
368 }
369
370 #[test]
371 fn verify_rejects_malformed() {
372 assert!(matches!(
373 hmac_verify(b"s", "k", b"msg", "no-colon-token"),
374 Err(HmacVerifyError::Malformed)
375 ));
376 assert!(matches!(
377 hmac_verify(b"s", "k", b"msg", "k:not-hex-zzzz"),
378 Err(HmacVerifyError::Malformed)
379 ));
380 }
381}