Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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 its auth() declaration names — which is why a signed endpoint keeps Auth::DEFAULT (S3 uses Auth::DEFAULT.optional(), so an anonymous reader can still find a bucket’s region). Auth::NONE hands the signer None, and a scheme that needs a key fails with SignError::missing_key. Two distinct key-material entries on one marker are refused as AmbiguousSigningKey.
  • outputs — the headers sign writes. The seal refuses a store placement into a declared output (PlacedOutputCollision): a placed credential the signature would overwrite is a misconfiguration worth naming. After each sign, the names are stamped into the request’s InjectedCredentials record, which is how fixture tooling classifies signer-written headers structurally — names travel, never values.
  • needs_body — whether the carrier should buffer the body before sign runs. A signer that reads the body itself reports false, 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 every OneShotSigner gets 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_response until Done or 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) — mutate head.headers / head.specs_mut(); take the material from key (SignError::missing_key when you need one and got None); read the body non-destructively with body.inspect_data() if your signature covers it; draw any nonce from ctx.rng().
  • outputs() — every header sign writes.
  • key() — leave it None unless the scheme signs with an entry other than the one the endpoint declares.
  • needs_body()true only if you want the carrier to buffer the body before sign runs.
  • handle_response (RequestSigner only) — return Retry (and set max_retries() above 0) only if your scheme renegotiates; return Err to reject an exchange you can’t verify; otherwise Done.

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.