cratestack_axum/idempotency/fingerprint.rs
1//! The idempotency layer's principal fingerprints: the default, and the
2//! pre-cratestack#1006 one kept public for a migration.
3
4use std::net::SocketAddr;
5use std::sync::Once;
6
7use axum::extract::{ConnectInfo, Request};
8use cratestack_core::CratestackError;
9use http::header;
10use sha2::{Digest, Sha256};
11
12use crate::ratelimit::VerifiedPrincipal;
13
14/// Logged once per process, not per request — a busy misconfigured
15/// deployment would otherwise emit this thousands of times a second. See
16/// `default_principal_fingerprint` for the condition that fires it.
17static MISSING_IDENTITY_WARNING: Once = Once::new();
18
19/// cratestack#416: the pre-existing default silently collapsed every
20/// unauthenticated caller without a verified peer address onto a single
21/// shared `"anonymous"` idempotency namespace — two distinct callers reusing
22/// an `Idempotency-Key` could then replay each other's response. Refusing
23/// the request instead (`PreconditionFailed`, matching this crate's
24/// established "handled error, not an unwind" shape) makes the gap loud in
25/// staging/CI instead of silently reachable in production, per the
26/// ticket's Expected Behavior: "construction requires an explicit
27/// fingerprint function so the collision cannot be reached by accident."
28pub(crate) fn default_principal_fingerprint(req: &Request) -> Result<String, CratestackError> {
29 // A principal an upstream layer verified (the COSE envelope layer's
30 // signer thumbprint, ADR 0006 §12, cratestack#1006) comes first, as it
31 // does in the rate limiter's default key: it is not caller-mintable, and
32 // a COSE-only client sends no `Authorization` header, so without this
33 // it was refused with the 412 below. The `princ:` prefix keeps it out of
34 // the other two namespaces (bare hex and an IP address never start with
35 // it), and it is hashed so an identifier is never stored verbatim.
36 if let Some(VerifiedPrincipal(principal)) = req.extensions().get::<VerifiedPrincipal>() {
37 let digest = Sha256::digest(principal.as_bytes());
38 let hex: String = digest.iter().map(|b| format!("{b:02x}")).collect();
39 return Ok(format!("princ:{hex}"));
40 }
41
42 legacy_principal_fingerprint(req)
43}
44
45/// The default fingerprint as it was before cratestack#1006, which put a
46/// `VerifiedPrincipal` (the envelope layer's signer) first: the SHA-256 hex
47/// of the `Authorization` header, else the `ConnectInfo` peer address,
48/// else a `412` (cratestack#416).
49///
50/// Public for one migration (API-review decision, 2026-09-26): a
51/// deployment that already inserts `VerifiedPrincipal` (its own middleware,
52/// or the envelope layer) moves to the `princ:` namespace on upgrade, so a
53/// key in flight across the deploy would run again instead of replaying.
54/// Install this with
55/// [`IdempotencyLayer::with_legacy_principal_fingerprint`](super::IdempotencyLayer::with_legacy_principal_fingerprint)
56/// for the deploy, and drop it once the idempotency TTL has passed.
57pub fn legacy_principal_fingerprint(req: &Request) -> Result<String, CratestackError> {
58 // Prefer Authorization header for authenticated requests.
59 if let Some(auth_header) = req.headers().get(header::AUTHORIZATION)
60 && let Ok(auth_str) = auth_header.to_str()
61 {
62 let mut h = Sha256::new();
63 h.update(auth_str.as_bytes());
64 // sha2 0.11 / digest 0.11 return `hybrid_array::Array`, which (unlike
65 // digest 0.10's `GenericArray`) implements no `LowerHex`. The
66 // byte-wise `{:02x}` fold below is this repo's existing hex idiom
67 // (`cratestack-core/src/transport.rs`) and is byte-for-byte what
68 // `format!("{:x}", …)` produced — this string is persisted/keyed on,
69 // so it must not change shape.
70 return Ok(h.finalize().iter().map(|b| format!("{b:02x}")).collect());
71 }
72
73 // Fall back to the real TCP peer address for unauthenticated requests, to
74 // avoid collisions between distinct callers. This is deliberately *not*
75 // `Forwarded`/`X-Forwarded-For`: those headers are client-supplied and
76 // this crate has no trusted-proxy configuration to verify or strip them,
77 // so trusting them here would let an attacker land in another caller's
78 // idempotency namespace just by guessing/spoofing that caller's apparent
79 // IP. `ConnectInfo` is populated by axum from the actual accepted socket
80 // (when the server is served via `into_make_service_with_connect_info::<SocketAddr>()`)
81 // and cannot be spoofed by the client.
82 if let Some(ConnectInfo(addr)) = req.extensions().get::<ConnectInfo<SocketAddr>>() {
83 return Ok(addr.ip().to_string());
84 }
85
86 // Neither Authorization nor a verified peer address is available (e.g.
87 // the server isn't wired through `into_make_service_with_connect_info`).
88 // There is no unforgeable value left to key on, so refuse rather than
89 // collapsing every such caller onto one shared namespace.
90 MISSING_IDENTITY_WARNING.call_once(|| {
91 tracing::warn!(
92 target: "cratestack",
93 cratestack_operation = "idempotency",
94 "IdempotencyLayer's default principal fingerprint has no Authorization header and \
95 no ConnectInfo<SocketAddr> peer on this request, so it cannot verify caller \
96 identity. Refusing the request rather than collapsing distinct callers onto a \
97 shared \"anonymous\" namespace (cratestack#416) — wire \
98 into_make_service_with_connect_info::<SocketAddr>() or supply \
99 IdempotencyLayer::with_principal_fingerprint(...) explicitly. Logged once per \
100 process; every matching request is refused until this is fixed.",
101 );
102 });
103 Err(CratestackError::PreconditionFailed(
104 "idempotency: no verifiable caller identity (Authorization header or ConnectInfo peer) \
105 is available for the default namespace fingerprint; the server must be served through \
106 into_make_service_with_connect_info::<SocketAddr>() or configure an explicit \
107 fingerprint function"
108 .to_owned(),
109 ))
110}