Skip to main content

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}