Appendix: AWS SigV4 contracts¶
The exact origin, signing, transport and failure contracts behind AWS request signing (SigV4). Start with the guide to configure an origin, credentials and a signed Amazon OpenSearch Service client.
The trusted origin¶
$origin is mandatory, and it is the whole of what this client will
sign for. There is no mode that signs whatever host a request happens to
name.
It must be an absolute http/https URI: a scheme, a host, an optional
port, and an optional path prefix, and nothing else. The host is a
registered name or a dotted-quad IPv4 address, http included so a
LocalStack or other AWS-compatible local endpoint works; an IPv6 origin
is out of scope. Userinfo, a query string, a fragment, a percent sign or
backslash in the authority, whitespace or a control character anywhere,
a malformed percent escape, a . or .. path segment, a non-numeric or
out-of-range port, and an invalid host are each rejected. Parsing
happens once, at construction, so a misconfigured endpoint fails
immediately rather than on the first request that needs it.
$region and $service are each 1 to 64 ASCII characters: a letter or
digit, then letters, digits, ., - or _. $service has no
default.
A configured origin, region, or service name that fails validation
throws Kinetis\AwsSigV4\Exception\SigningException from
new SigV4SigningClient(...), not from sendRequest(). Its message
names the field and the rule, never the value — a rejected origin can
carry a password in its userinfo or a token in its query string.
Scheme and host are compared case-insensitively, and an absent port
means 80 for http and 443 for https — so https://api.example.com
and https://API.Example.com:443 are the same origin.
Every request is checked against it before anything else happens:
A relative request (
/users, orusers, which PSR-7 permits) is resolved against the origin. Its path is joined to the configured prefix with exactly one slash regardless of which side already supplies one, sousersunder the prefix/prodresolves to/prod/users, not/produsers. The query string is untouched.An absolute request must already name the origin exactly, and keeps the path it came with.
The path prefix binds both: a target that lies outside it —
https://api.example.com/adminunder the originhttps://api.example.com/prod, or a relative../admin— is rejected. The comparison is segment-wise, so/productiondoes not pass for the prefix/prod.Anything else is rejected: another host, another port, an
httptarget under anhttpsorigin, a//host/pathnetwork-path reference, userinfo, a target carrying a control character, a backslash, or a malformed percent escape.
A rejection throws
Kinetis\AwsSigV4\Exception\UntrustedOriginException before the
credential provider is called, before the request body is read, and
before the transport is touched.
What is signed is what is sent¶
A signature covers a path and a query string, so a target that changes
between signing and sending is a signature over something that never
went out. HTTP clients normalize on the way out — /a/../b becomes
/b, /%7Efoo becomes /~foo — so this package normalizes first, by
its own rule, and checks the origin and the path prefix against that
final form:
A percent escape standing for an unreserved character (
A-Z,a-z,0-9,-,.,_,~) is decoded; RFC 3986 calls the two spellings one target.Every other escape keeps its bytes and takes uppercase hex digits.
%2Fstays an encoded slash rather than becoming a segment separator.A character outside the unreserved set, the sub-delimiters,
:,@and/is percent-encoded.The path then has its repeated
/collapsed to one, and its.and..segments removed after decoding, so a segment spelled%2E%2Ecounts as one. An empty path becomes/, and//example//becomes/example/.A fragment is dropped: it never reaches an HTTP request line.
The URI that is signed and sent is then built from the origin’s own canonical scheme, host and port, so the authority a signature covers is the configured one however the request spelled it, and the outgoing request itself is built by this package rather than carried over from the caller’s PSR-7 object. The rule applied to its own output changes nothing, which is what makes the target the transport sends byte-identical to the one the signature was computed over.
The practical consequence: /prod/../../secrets under the origin
https://api.example.com/prod is rejected rather than signed for
/prod/../../secrets and sent to /secrets.
The canonical request¶
The signature is computed over the request in that final wire form. Both sides of a SigV4 signature derive the same canonical text from the same bytes, so the rules below are what the service applies too:
URI — the wire path’s own bytes percent-encoded segment by segment, with
/left as/. Everything outside the unreserved set becomes%XX, the%of an escape already on the wire included: wire/a%2Fbis canonically/a%252Fb, and/a/bis/a/b. The request line carries neither — only the canonical text takes that second encoding layer, which is what the service applies to the target it receives, so two targets that differ on the wire sign apart.Query — each
&-separated pair split at its first=, name and value decoded and re-encoded, and pairs sorted bytewise by encoded name then encoded value with duplicates kept.?a=10&a=9canonicalizes toa=10&a=9, since1sorts before9. A literal+is the plus character and encodes as%2B; a space is%20.Headers — names lowercased and sorted bytewise, values trimmed with internal whitespace collapsed to one space, and a repeated header’s values joined with
,in the order the request holds them. The outgoing request keeps every value separately: the join is canonical input, not what is sent.
Host, X-Amz-Date, Authorization and X-Amz-Security-Token belong
to the client and are written over whatever the request carried. Host
comes from the trusted origin’s own authority, and the security-token
header is removed when the resolved credentials carry no token. Every
other header on the request is signed and sent as you wrote it —
Content-Type included, since it decides how a service reads the body —
apart from the payload-hash header below. A header added by the
transport’s default options is sent but not signed.
The headers left out of the signature are the ones an HTTP client owns
on the way out: Authorization, Content-Length, Expect,
User-Agent, Accept-Encoding, Connection, Transfer-Encoding,
TE, and Proxy-Authorization. Signing one of those binds the
signature to a value this package does not control.
X-Amz-Content-Sha256 is not added. Set one yourself — under any
spelling, however many times — and the request goes out carrying a
single value, the SHA-256 of the body bytes that were read. The header a
service reads as the payload hash names the same bytes the canonical
request does, so a value of your own cannot point the two at different
payloads.
Redirects, retries and the transport¶
One sendRequest() is one network attempt, and a 3xx response is
returned as the response — nothing is re-signed and no second request is
made, so a Location cannot carry an Authorization or
X-Amz-Security-Token header off the configured origin. Follow one by
checking the status and sending a new request of your own.
That holds because the transport is Kinetis\AwsSigV4\SignedTransport,
built by this package and wrapped in Symfony’s PSR-18 adapter. It
forwards one delegate call per request, with max_redirects => 0
written onto the request itself rather than left in a default option
another layer could merge over. Its delegate is a Symfony
AmpHttpClient over a bare Amp\Http\Client\PooledHttpClient: the
AMPHP client configurator is pinned to one that installs no interceptor,
so nothing in the chain retries a request or follows a Location.
Both halves of that need owning rather than configuring. An AMPHP client
configurator is what installs an interceptor: RetryRequests replays a
request below the PSR-18 boundary where no option and no caller sees it
happen, and FollowRedirects sends one on, with AMPHP keeping
Authorization across a redirect whose authority matches — an https
to http hop on the same host included. Above the same boundary,
Symfony’s RetryableHttpClient with a strategy that treats a 302 as
retryable sends the signed request a second time and answers with what
the retry said, and ScopingHttpClient merges its own per-URL defaults
over every request it forwards.
So transport: takes a SignedTransport and nothing else. Its
constructor is private, and create() takes default options — the
deadlines below, headers an endpoint always needs — so no client and no
configurator of yours goes underneath a signature. Put a wrapping client
above SigV4SigningClient instead, where a replay costs a fresh
signature and is visible as one. A max_redirects in those options is
overridden. With no transport: argument, the client builds
SignedTransport::create() and uses it for the signed request and the
default credential chain.
In a test, SignedTransport::answeredInProcess() answers from a
function of your own on the calling thread and opens no connection. The
function receives the method, the URL and the merged options of each
request:
use Kinetis\AwsSigV4\SignedTransport;
use Symfony\Component\HttpClient\Response\MockResponse;
$transport = SignedTransport::answeredInProcess(
static fn (string $method, string $url, array $options): MockResponse
=> new MockResponse('{"acknowledged":true}', ['http_code' => 200]),
);
Deadlines¶
Every request through SignedTransport::create() is bounded twice:
timeout, 30 seconds by default, is the longest gap it waits between
bytes, and max_duration, also 30 seconds, bounds it end to end. The
total bound is the finite one, since a peer that trickles a byte at a
time never goes idle. Either takes a value of your own without moving
the other.
sendRequest() covers the request through the response headers and
returns there. The body is read lazily behind that, so a read of it that
outlives the same deadline, or whose connection fails, throws a PSR
stream RuntimeException rather than anything sendRequest() raises.
Such a message can name the URL it was reading, which carries no
credential: this package signs with the Authorization and
X-Amz-Security-Token headers only.
Credential resolution¶
With no provider passed, credentials are resolved through AsyncAws’s
five providers, in AsyncAws’s order: environment variables (including
the STS assume-role that AWS_ROLE_ARN selects), web identity, the
shared credentials and config files, ECS or EKS pod identity, then IMDS.
Every provider in it that calls AWS uses the same SignedTransport the
signed request travels on, so a configured metadata token is sent to the
endpoint that was configured and to nothing a Location names.
The first unexpired credentials a lookup resolves are held and reused until they expire; credentials with no expiry are held for the life of the client. A provider that answers with nothing, or with credentials that have already expired, is passed over and the same lookup continues down the chain. A lookup that reaches the end of the chain without an answer holds nothing, so a transient ECS, IMDS or token-file failure costs one lookup rather than the worker’s remaining lifetime.
Pass a CredentialProvider as credentialProvider: to use something
else. A provider passed that way replaces the chain entirely and stays
yours: the client holds nothing it returns, so a provider that calls a
service on every lookup does so for every request.
use AsyncAws\Core\Credentials\Credentials;
use Kinetis\AwsSigV4\SigV4SigningClient;
$client = new SigV4SigningClient(
origin: 'https://search-orders-abc123.eu-west-1.es.amazonaws.com',
region: 'eu-west-1',
service: 'es',
credentialProvider: new Credentials($accessKeyId, $secretAccessKey),
);
Request bodies and blocking work¶
SigV4 signs over the body’s exact bytes, so sendRequest() reads the
request’s body into memory as a plain string. A large body is fully
buffered, not streamed, and peak memory during a signed request is a
multiple of the body’s own size rather than bounded by it. There is no
size ceiling on what this client will sign; a ceiling on what may be
uploaded to S3 belongs to Storage (S3), where such an upload is
built.
Signing consumes the body. The stream is read once, from wherever its cursor already sits through to EOF, and a fresh stream built from those bytes is what gets signed and sent. Your own stream is left at its end. A body positioned mid-stream is signed and sent from that position, and one that has already been read signs and sends as empty — so pass a body positioned where you want it read, and pass it once. A stream that cannot be seeked at all (PSR-7 permits these — a chunked body, a pipe) needs nothing special.
A request through the transport suspends the calling Fiber rather than
blocking it, and so does every credential lookup that reaches the
network: SignedTransport is AMPHP-backed. The rest is synchronous work
on the calling thread: the shared credentials and config files, an SSO
cache file, and a web identity token file are read with blocking
filesystem calls, on first resolution and on each refresh, and capturing
and hashing the request body is CPU work.
Failures¶
SigV4SigningClient implements Psr\Http\Client\ClientInterface, and
every failure it raises implements ClientExceptionInterface, so one
catch around sendRequest() covers all of them:
Exception |
PSR-18 category |
Raised when |
|---|---|---|
|
|
the request target does not resolve to the configured origin |
|
|
credentials could not be resolved, the credential provider failed, the body could not be captured, or signing failed |
|
|
the transport rejected the signed request — an option or a URL it would not accept |
|
|
the connection could not be made, was lost, or timed out |
All four are in Kinetis\AwsSigV4\Exception. PSR-18 requires the last
of those to be distinguishable from the rest: a request the transport
will not accept fails the same way again, and a connection failure
leaves the request’s outcome unknown. Retrying is yours to decide and
costs a fresh signature — this client signs one request per
sendRequest() call and never replays one. The AWS request signing (SigV4) guide
states what to check before repeating a write.
Each carries a fixed message naming only that category, and
getRequest() returns the request you passed to sendRequest() — never
a resolved, normalized or signed one, so a signed Authorization or
X-Amz-Security-Token header has no way out through it.
No cause is chained. A credential provider, URI parser, signer, or
transport Throwable carries endpoint text, token file contents, or the
signed request in its own message and trace, and a chained cause reaches
every ordinary error channel — (string) $e, PSR-3 normalization, a
getPrevious() walk. Those causes are discarded rather than stored:
diagnose transport problems through the transport’s own logger, which
sees the real failure before this package converts it.
A stack trace holds the arguments each frame was called with when
zend.exception_ignore_args is off. This package’s own parameters are
marked #[SensitiveParameter], and PHP refuses to serialize a
SensitiveParameterValue — so with arguments recorded, serialize() on
one of these exceptions throws rather than producing a payload that
would need auditing for what it carries. Log the message and the
endpoint you were reaching.
Standalone use¶
kinetis/aws-sigv4 depends on kinetis/revolt-http-client, AsyncAws’s
core package, nyholm/psr7, and the PSR and Symfony HTTP contracts —
not on kinetis/framework. Any PSR-7 request works. Under an API
Gateway stage origin, a relative target is joined to the stage path:
use Kinetis\AwsSigV4\SigV4SigningClient;
use Nyholm\Psr7\Request;
$api = new SigV4SigningClient(
origin: 'https://abc123.execute-api.us-east-1.amazonaws.com/prod',
region: 'us-east-1',
service: 'execute-api',
);
// Signed for, and sent to, /prod/orders?status=open on that origin.
$response = $api->sendRequest(new Request('GET', 'orders?status=open'));
Construct it once and reuse it, for the same credential and connection reuse the guide describes.
See also¶
AWS request signing (SigV4) — configure an origin, credentials and a signed OpenSearch client.
HTTP Client — the non-blocking HTTP client the transport is built on.