Request Signers and Presigning
Some schemes present no stored secret at all. They compute a credential over the request itself — its method, path, headers, and often its body — and the signature is only valid for the exact bytes that leave the machine. AWS SigV4, Azure Storage SharedKey, and HTTP Digest are the built-ins. Because the signature depends on the finished request, signing runs as the first act of every transmit attempt: after every pipeline member has shaped the request and after the seal has placed the stored credentials, with nothing between it and the wire.
The credential and the signer are held apart on purpose. The signer is
credential-free typed state on the config — Sigv4Signer::new(region, service)
knows how to sign and holds no key. The key lives in the config’s
AuthStore, as an AuthEntry carrying custody key material, and the seal maps
it to the signer per request. That is what the crate calls the authentication
plane: the store, the schemes, the signers, and presigning are one mechanism,
and a request signer is one capability inside it.
The contract
Two traits, both from capi_authentication and re-exported through
capi_core::authentication and the prelude. Which one a scheme implements says
whether it reads responses.
Most schemes never look at the reply, and implement OneShotSigner:
pub trait OneShotSigner: MaybeSend + MaybeSync + 'static {
fn sign(
&self,
ctx: &ApiContext,
head: &mut RequestHead,
body: &mut Bytestream,
key: Option<&KeyMaterial>,
) -> Result<(), SignError>;
fn needs_body(&self) -> bool { false } // should the carrier buffer the body first?
fn outputs(&self) -> Vec<HeaderName> { Vec::new() } // the headers this signer writes
fn key(&self) -> Option<AuthKey> { None } // the store entry whose material feeds it
fn as_presign_url(&self) -> Option<&dyn PresignUrl> { None }
fn as_presign_policy(&self) -> Option<&dyn PresignPolicy> { None }
}
A scheme that does read the reply — Digest answering a challenge, or anything
verifying a response signature — implements RequestSigner, which adds the
response leg and the retry budget:
pub trait RequestSigner: MaybeSend + MaybeSync + 'static {
fn sign(&self, ctx: &ApiContext, head: &mut RequestHead, body: &mut Bytestream,
key: Option<&KeyMaterial>) -> Result<(), SignError>;
fn handle_response(&self, ctx: &ApiContext, response: &mut Response)
-> Result<SigningOutcome, SignError>;
fn needs_body(&self) -> bool { false }
fn max_retries(&self) -> u8 { 0 } // > 0 selects the challenge/retry path
// outputs, key, and the two presign upcasts, as above
}
handle_response returns SigningOutcome::Done, ::Retry (the hook that
powers Digest’s challenge), or an Err — which fails the exchange rather
than returning the response, so a scheme that verifies a response signature can
refuse one it doesn’t trust. A blanket impl makes every OneShotSigner a
RequestSigner with an empty response leg, so implementing the narrow trait
gives up nothing; coherence then prevents a type from implementing both.
On the async lane both sign and handle_response return boxed futures —
SignFuture<'a> and OutcomeFuture<'a>, Send unless the bounds are relaxed —
because a signature is not always local: a custody backend may be an HSM, a
KMS, or WebCrypto, and the signer awaits it.
The four members beyond sign each carry a contract:
key— the store entry whose material this signer needs. A named key is absolute: the seal resolves exactly that entry. A signer naming none (the common one-signer case) receives the endpoint’s single key-material credential — the entry itsauth()declaration names — which is why a signed endpoint keepsAuth::DEFAULT(S3 usesAuth::DEFAULT.optional(), so an anonymous reader can still find a bucket’s region).Auth::NONEhands the signerNone, and a scheme that needs a key fails withSignError::missing_key. Two distinct key-material entries on one marker are refused asAmbiguousSigningKey.outputs— the headerssignwrites. The seal refuses a store placement into a declared output (PlacedOutputCollision): a placed credential the signature would overwrite is a misconfiguration worth naming. After eachsign, the names are stamped into the request’sInjectedCredentialsrecord, which is how fixture tooling classifies signer-written headers structurally — names travel, never values.needs_body— whether the carrier should buffer the body beforesignruns. A signer that reads the body itself reportsfalse, which is what lets a body it does not need to read stay unread.as_presign_url/as_presign_policy— the upcasts to the presigning capabilities, below, so one installed signer serves both jobs.
Key custody
sign never sees key bytes. The KeyMaterial it receives pairs an opaque
KeyHandle with the scheme-public identity it signs under — AWS’s access key
ID, JOSE’s kid — and the two travel as one value so they cannot separate on
rotation. The handle exposes three operations, each with its algorithm from a
closed enum: mac (a keyed MAC over a message), sign (an asymmetric
signature), and derive (a scoped sub-key, returned as a new handle, so a
derivation ladder such as SigV4’s four-step signing key maps onto chained
handles). The framework exports nothing. A scheme whose primitive is none of
those three ships its own Custody type and reaches it through
KeyHandle::backend; that type draws its own perimeter, under the same rule —
operations on the secret, never the secret.
Custody is the backend seam behind the handle: one implementation per held
key. The crate ships in-memory custody — InMemorySecret for symmetric keys,
InMemoryRsaKey for RS256 behind the rsa feature — which zeroes its bytes on
drop; a remote backend implements the same trait, and because its operations
are I/O the async twins return boxed futures. A credential carrier turns itself
into a store entry by building that pair:
let material = KeyMaterial::new(
KeyHandle::new(InMemorySecret::new(root)), // "AWS4" + the secret key, in custody
self.access_key_id, // the identity SigV4 signs under
);
match &self.session_token {
Some(token) => AuthEntry::new(X_AMZ_SECURITY_TOKEN, token.as_str()).with_key_material(material),
None => AuthEntry::from_key_material(material),
}
Temporary STS credentials are the two-part case: a placed session-token header beside the custody-held signing key, in one entry, so a refresher rotates the token, the key, and the identity atomically.
A scheme carrying its own backend builds the same pair onto that instead.
RFC 7616’s hash forms are neither a MAC nor a signature, so HTTP Digest’s
carrier puts the password in a private Custody type that leaves all three
operations refused and computes inside itself:
let identity = self.username.clone();
let secret = DigestSecret { // private; `impl Custody for DigestSecret {}`
username: self.username,
password: self.password, // Zeroizing, with no accessor
};
AuthEntry::from_key_material(KeyMaterial::new(KeyHandle::new(secret), identity))
sign asks for that backend back with key.handle().backend::<DigestSecret>()
and calls the scheme’s own operation on it, so the credential is an ordinary
store entry with the store’s expiry, renewal and roles; SignErrorKind::KeyMismatch
is the refusal when the entry under that key belongs to another scheme.
The carrier
A resolved signer travels as a Signing value: nothing (NO_SIGNING), a
&'static signer, an Arc-shared one, or the two-layer composite below.
Signing is also what applies it, at transmit — the carrier primes the body
when the signer asks for it, maps the key, and calls sign.
Two layers, two owners
A request can carry two signatures, and they answer to different people.
The service scheme is protocol: if the API demands a signature, the endpoint must produce one. So the endpoint declares it, and the default is nothing:
// Endpoint — defaults to NO_SIGNING, so an endpoint that says nothing sends unsigned.
fn signer(&self, config: &Self::ApiConfig, _context: &ApiContext) -> impl ToSigner {
config.service_signing()
}
That costs one line per signed endpoint and buys a real property: reading an endpoint impl tells you whether its requests are signed. An endpoint whose authorization rides the body or the query string simply says nothing.
The cosigner is the user’s own outer layer — an egress proxy that wants its credential on everything leaving the network:
// ApiConfig — the user's layer. Defaults to NO_COSIGNING; BaseConfig answers it
// from its cosigner cell, and a wrapping config forwards it.
fn cosigner(&self) -> impl ToCosigner { self.config.cosigner() }
It applies over the assembled request as-is, service signature included, and
no endpoint can see or suppress it. Signing::cosign folds the two into the
single carrier the request plumbing carries: either side empty returns the
other unwrapped, both present sign inner-first, so the cosignature covers the
service signature — which is what a proxy that verifies or strips the outer
layer needs. Signing::layer_keys() reports each layer’s declared key, and the
seal maps a credential for each.
The cosign slot admits only OneShotSigner implementors — Cosigning’s
constructors are bounded on it. Under a composite, one layer has to own the
retry loop, and that owner is the scheme speaking the protocol; a challenge
scheme in the cosign slot is a compile error rather than a signature that
silently never gets its challenge. A cosigner therefore never reads responses.
ToSigner and ToCosigner are sealed and accept exactly three shapes each: a
&'static S, an Arc<S>, and an already-built carrier. A new scheme becomes
usable by implementing a signer trait, never by implementing these.
Assembly resolves both layers, alongside the rate limiter and the credential
store, into the traversal’s RequestPlan. One request can adjust its own
layers through RequestOverrides:
RequestOverrides::new().with_cosigner(my_signer) // countersign just this one
RequestOverrides::new().no_cosigner() // don't countersign this one
RequestOverrides::new().no_signing() // send without the service signature
There is deliberately no with_signer beside no_signing. Which requests the
service requires a signature on is protocol, and the endpoint declares it; the
one thing a call site legitimately knows better is that this request is not
going to the service on its own — a batch or changeset member captured to ride
inside an envelope that authorizes for the whole group, where a signature
computed over a request that never leaves as a request would at best be wasted
and at worst a credential sealed inside an envelope body.
Installing a service signer
The signer is ordinary typed state on the client’s config, the way a codec is,
and the credentials are a store entry. aws_s3 uses the pattern end to end:
// config.rs — the store plus a slot for the one scheme S3 speaks
auth: AuthStore,
sigv4: SharedCell<Option<Sigv4Signer>>,
pub fn set_credentials(&self, credentials: AwsCredentials, region: impl Into<String>) {
self.auth.set_auth_entry(AuthKey::Default, credentials.into_auth_entry());
self.sigv4.set(Some(Sigv4Signer::new(region, SIGV4_SERVICE)));
}
pub fn service_signing(&self) -> Signing {
self.sigv4.with(|s| s.clone().map(Signing::new).unwrap_or(NO_SIGNING))
}
// interface.rs — authenticate() installs credentials at runtime
pub fn authenticate(&self, access_key_id: impl Into<String>, secret_access_key: impl Into<String>,
region: impl Into<String>, session_token: Option<String>) {
let credentials = match session_token {
Some(t) => AwsCredentials::with_session_token(access_key_id, secret_access_key, t),
None => AwsCredentials::new(access_key_id, secret_access_key),
};
self.inner.config().set_credentials(credentials, region);
}
Every signed endpoint then returns config.service_signing() from its
signer. Before authenticate runs the slot is empty and the marker resolves
against an empty store, so a send fails locally with MissingAuth rather than
reaching the service unsigned. S3’s POST Object is the one endpoint that
declares no signer: its authorization rides the form body, and the SigV4 it
does need travels on the body itself (below).
azure_tables is the same shape with a different signer: SharedKeySigner::table()
in the slot, AzureKeyCredential::new(account, base64_key)?.into_auth_entry()
in the store — the decoded key in custody, the account name as its identity.
The user’s cosigner is installed separately, on BaseConfig:
config.set_cosigner(EgressSigner::new(credentials));
Recall from the wiring chapter that a signing client names no
scheme: its API type stores BaseInterface directly, and authenticate is a
hand-written method that installs the credentials.
Streaming bodies
A signer that reads the body meets a limit the framework refuses to guess at.
Priming a live stream for such a signer would buffer every byte of it — an
upload of unknown size becoming an allocation of unknown size, silently, at the
last moment before the wire. So the carrier asks the stream what it is, and a
BodySource::Streaming answer stops the exchange with a typed
StreamingBodyRefused instead of materializing it.
Two ways forward. A scheme that would otherwise cover the payload can be told
not to: SigV4 reads x-amz-content-sha256: UNSIGNED-PAYLOAD (or
STREAMING-UNSIGNED-PAYLOAD-TRAILER) as an instruction to sign that token
literally in the payload-hash slot and never read the body — which is how an
aws-chunked upload with a trailing checksum, framed by capie_aws_chunked,
goes out unbuffered. Otherwise the caller decides the body fits in memory and
hands over a buffered one; that judgement is the developer’s, not a limit for
the framework to guess.
One-shot vs. challenge
Which path runs is the signer’s declaration, not the traversal’s:
max_retries() == 0— what everyOneShotSignergets from the blanket impl. Sign once, forward once: no head snapshot, no body clone. SigV4, SharedKey, vendor HMAC schemes, and the overwhelming majority.max_retries() > 0— snapshot the head, take a lazy replay clone of the body, and loop sign → send →handle_responseuntilDoneor the budget is spent.
handle_response is consulted on every exchange either way; the budget only
bounds whether a Retry is honored. A one-shot signer pays one virtual call for
it and nothing else.
The replay clone shares the body’s buffer rather than copying it, so an empty-body Digest GET costs nothing and a seekable body replays by reading its source again; a streaming body keeps its transmitted bytes in the shared buffer until the exchange settles, which is what re-sending a body that can only be read once costs. The challenge loop runs inside the traversal’s transmit step, below every pipeline member, so a handshake round trip doesn’t re-run them.
The built-ins
AWS SigV4 (capi_auth_aws_sigv4) — AwsCredentials (new(access_key, secret) or with_session_token(..) for STS; into_auth_entry() builds the
store entry) and the credential-free Sigv4Signer::new(region, service). A
one-shot signer: needs_body() is false because it reads the body itself in
sign when no unsigned-payload token is present, max_retries() is 0, and
its declared outputs are Authorization, x-amz-date, and
x-amz-content-sha256. It also presigns, and
Sigv4Signer::bedrock_api_key(&entry, epoch_secs, expires_secs) mints, with no
network call, the bearer token Amazon Bedrock accepts in place of a signature —
a SigV4-presigned request wrapped in the envelope AWS expects.
Azure Storage SharedKey (capi_auth_azure) — SharedKeySigner, a
OneShotSigner computing Authorization: SharedKey {account}:{signature} over
the canonicalized headers and resource; SharedKeySigner::table() is the Table
service’s flavour. It reads nothing on the way back, which is what makes it
eligible for either signing slot. azure_tables is the consumer.
HTTP Digest (capi_auth_digest, RFC 7616) — the challenge-response case,
and the reason handle_response and max_retries exist.
DigestCredentials::new(username, password).into_auth_entry() is the store
entry, and DigestSigner is credential-free: an Arc<DigestSigner> is a signer
handle the framework accepts directly, and sign reaches the password through
KeyHandle::backend on the key material the seal mapped — so a protected
endpoint declares Auth::DEFAULT beside its signer line. Its
needs_body() is true (until the challenge arrives it cannot
know whether qop=auth-int covers the body) and max_retries() is 1,
because Digest works in two passes:
sequenceDiagram
participant C as Signing
participant S as DigestSigner
participant Srv as Server
C->>S: sign(head, body) — no challenge yet, send bare
C->>Srv: request
Srv-->>C: 401 + WWW-Authenticate: Digest …
C->>S: handle_response(401) → parse & cache challenge → Retry
C->>S: sign(head, body) — now compute Authorization from the challenge
C->>Srv: retried request
Srv-->>C: 200 OK → Done
The cached challenge and nonce counter are per-realm state, so every
endpoint against one realm must reach the same signer: keep one on the config
and hand a clone to each, rather than building one per endpoint. The credential
is per entry rather than per signer, so that one signer still serves several
accounts — as_role("ops") picks the entry a request signs under. The cnonce
is drawn from ctx.rng(), so the flow is deterministic under replay
tests.
Presigning
Presigning mints a credential-bearing artifact for a third party to use
later: a URL a browser can GET without credentials, or the form fields a
browser posts alongside a file. Nothing is sent when it’s produced, and the
payload is normally unsigned, so it can’t be sign — it’s a separate pair of
traits a scheme opts into and reaches through the two upcasts:
fn as_presign_url(&self) -> Option<&dyn PresignUrl> { None }
fn as_presign_policy(&self) -> Option<&dyn PresignPolicy> { None }
Signing forwards through those upcasts, so the value that signs a client’s
requests is the value that presigns — a config needs no second handle to it. A
signer that only signs in band reports SignErrorKind::Unsupported. Presigned
artifacts never traverse the pipeline, so their credential resolution cannot
ride the seal; capi_core::presign is the front door instead. It reads the
config’s store, resolves the named credential strictly — no store, a lookup
miss, or a source failure is an immediate typed error — and hands the resolved
entry (key material, and any placed part the artifact must embed, such as a
session token) to the signer’s presigning capability. A client wraps it as its
own method:
let ctx = config.base_context().prepare();
let spec = PresignSpec::new(Method::GET, url, Duration::from_secs(900));
let url = presign::presign_url(config, &ctx, &AuthKey::Default, config.service_signing(), &spec).await?;
Both presign functions are async on the async lane, because a signature isn’t
always local — keyless GCP calls signBlob, CloudFront and CloudHSM keys live
in KMS, Azure user-delegation SAS fetches a delegation key — so a presigner can
hold a client of its own and await it.
Policy signing takes two calls, because the document has to commit to the credential fields before it can be signed: S3 requires a condition for every form field it’s submitted with, including the signer’s own.
let signer = config.service_signing();
let begun = presign::begin_policy(config, &ctx, &AuthKey::Default, signer).await?;
let document = /* the caller's conditions plus begun.fields(), encoded */;
let signed = presign::finish_policy(&ctx, signer, &begun, &document).await?;
begin_policy captures the one instant the whole artifact is signed under — the
expiry, the credential scope, and the signature all derive from it, and a second
clock read could straddle a second boundary and leave them disagreeing. It reads
that instant through the record/replay clock, so a presigned artifact captured
in a fixture reproduces byte-for-byte. finish_policy needs neither the config
nor the store — the PolicyPresign carries the resolution snapshot — so it is
capi_authentication’s own function, re-exported from capi_core::presign to
keep the three steps under one path.
A body that carries its own authorization
An endpoint whose authorization rides inside the body — S3’s POST Object, GCS
POST policy — can’t call any of that itself: Endpoint::body is synchronous
and performs no I/O, because it describes a request rather than performing
one. So it hands over its two pure steps and lets request assembly sign
between them, by returning the PolicySigned arm of RequestBody — the
scheme that signs the policy beside the body’s two steps:
fn body(self, config: &Self::ApiConfig, context: &ApiContext) -> Result<impl ToBody, BodyError> {
Ok(RequestBody::PolicySigned {
signing: config.service_signing(), // what signs the policy
body: Box::new(PostObjectForm {
json: config.json_lib(),
rng: context.rng().clone(), // this request's own RNG fork, for the boundary
policy: self.policy,
file: self.file,
}),
})
}
impl PolicyBody for PostObjectForm {
// 1. encode the document, given the fields it must commit to
fn document(&self, begun: &PolicyPresign) -> Result<Vec<u8>, BodyError> {
encode(&self.json, &self.policy.to_document(begun))
}
// 3. assemble the form from the document and its signed fields
fn assemble(self: Box<Self>, document: &[u8], signed: &PresignedPolicy)
-> Result<PreparedBody, BodyError>
{
build_form(document, signed, self.file, &self.rng)
}
}
Assembly matches that arm, calls begin_policy, runs step 1, calls
finish_policy, then runs step 3. Every other body is RequestBody::Ready and
is untouched. document borrows and assemble takes Box<Self>, so one value
owns everything both steps need — and assemble is unreachable without a
PresignedPolicy in hand, which makes the ordering structural rather than
checked. For a body with no fields worth naming a type over,
PolicySignedBody::new(signing, document, assemble) is the same shape built
from a pair of closures.
Such an endpoint returns NO_SIGNING from signer() and signs a policy:
two different jobs on one request, and they do not have to agree. The request
carrier decides whether an Authorization header is written; the policy is
signed with the config’s credentials because that’s what the service verifies
the upload against.
Signing a redacted request
A request captured for a fixture carries the shape of its signatures, not
their values. When an endpoint’s declaration is Auth::DEFAULT.redacted(), or
a send carries RequestOverrides::redact_credentials(), the seal maps the keys
with redact set and the carrier writes REDACTED_PLACEHOLDER into every
header the signer declares in outputs() instead of signing. No custody
operation runs, so the request never holds a credential — which is why
outputs() must name every header sign writes. The
redaction chapter has the rest of that story.
Writing a custom signer
For a scheme that isn’t one of the built-ins, implement OneShotSigner — or
RequestSigner if it reads responses:
sign(ctx, head, body, key)— mutatehead.headers/head.specs_mut(); take the material fromkey(SignError::missing_keywhen you need one and gotNone); read the body non-destructively withbody.inspect_data()if your signature covers it; draw any nonce fromctx.rng().outputs()— every headersignwrites.key()— leave itNoneunless the scheme signs with an entry other than the one the endpoint declares.needs_body()—trueonly if you want the carrier to buffer the body beforesignruns.handle_response(RequestSigneronly) — returnRetry(and setmax_retries()above 0) only if your scheme renegotiates; returnErrto reject an exchange you can’t verify; otherwiseDone.
Errors carry the scheme that produced them, so a failure stays legible when more than one signer is in play:
SignError::missing_key("AWS4-HMAC-SHA256")
SignError::missing_state("Digest", "no cached challenge for this realm")
SignError::invalid_input("AWS4-HMAC-SHA256", SignLocus::Header(name), "not canonicalizable")
SignError::scheme_failure("SharedKey", detail)
SignError::unsupported("Digest", "presign_url")
That completes authentication. Errors and Decoders turns to the response side — modeling your domain error.