Skip to content

fix(aws-sigv4): sign with the injected clock, not new Date() - #667

Merged
rejifald merged 1 commit into
mainfrom
claude/sigv4-inject-clock-658
Aug 5, 2026
Merged

fix(aws-sigv4): sign with the injected clock, not new Date()#667
rejifald merged 1 commit into
mainfrom
claude/sigv4-inject-clock-658

Conversation

@rejifald

@rejifald rejifald commented Aug 5, 2026

Copy link
Copy Markdown
Owner

Fixes §2 only of #658"SigV4 signs with new Date() rather than the injected clock".

Root cause

packages/aws-sigv4/src/index.ts:301 stamped the signature with
amzDateOf(new Date()). On real time that is correct, so nothing was ever wrong on the
wire
— but it put SigV4's one time-driven input outside the Clock seam, which made the
strategy untestable on virtual time:

  • 600 virtual seconds moved the shipped stamp 0 seconds (a clock-reading signer moved 600).
  • Under a default manualClock(), 0 of 3 calls were accepted — ~20,670 days of apparent
    skew, because the virtual clock starts at epoch while the server's validation reads real time.
  • Every proof in that scenario had to inject its own clock-reading signer to measure anything.

The signing timestamp is control-flow time by ADR 0010's
own definition: x-amz-date is inside the string-to-sign, and AWS refuses a stamp more than
~5 minutes out with RequestTimeTooSkewed. It decides whether the call is accepted, so it
belongs on the same seam that already drives retry, throttle, timeout, circuit and OAuth2 token
freshness.

The fix — core's pattern, mirrored exactly

#664 landed clock?: Clock on AuthContext, threaded by the engine from the same place
Runtime.clock comes from. That is the seam this uses. packages/core/src/auth.ts:432 consumes
it as:

const clockNow = (ctx: AuthContext): number => ctx.clock?.now() ?? now();

This package now carries the identical helper (now() is core-internal, and core's
now = () => Date.now(), so the companion inlines that one call rather than spending a core
export on it):

const clockNow = (ctx: AuthContext): number => ctx.clock?.now() ?? Date.now();

The whole behavioural change is one line — amzDateOf(new Date())
amzDateOf(new Date(clockNow(ctx))). ctx was already in scope. The optional field plus the
fallback means a hand-built AuthContext in a custom strategy's unit test still type-checks.
AuthContext is imported as a type, from the stitchapi peer that is already required — no
new dependency, no structural copy of the type (the same reasoning the package's Secret
re-export already documents).

The real-time wire output is unchanged

This is a testability fix, not a wire fix. The engine threads systemClock unless a clock was
injected, and systemClock.now() is Date.now(). Two tests pin it:

  1. Byte-identical, proven without a race. Sign a request on the wall clock (no ctx.clock),
    read back the instant it actually stamped, then re-sign the identical request on a clock
    pinned to that instant. x-amz-date, x-amz-content-sha256 and the full Authorization
    header must match exactly. This test passes both before and after the change — that is
    the point of it.
  2. A golden signature at a fixed instant. The literal is not "whatever the code emits": it
    was cross-checked against an independent SigV4 implementation written from the AWS spec
    against node:crypto, which first reproduces the official aws-sig-v4-test-suite
    get-vanilla vector (5fa00fa3…) to prove the oracle itself, then produces
    726c5c4879a6b4ccbbd3b24edbd6b8826d34f87450fbbf4e85546fc7ba9c1642 for the header set the
    strategy attaches. The package agrees.

Payload hashing is untouched, and the signBody branches from #401 — including
multipart + signBody: true still throwing — are unchanged and still covered.

The failing test

Written first, and confirmed failing against unmodified src: 5 failed | 18 passed. The
five that failed were the clock assertions; the byte-identical wire pin was among the 18 that
passed, as it must be. After the one-line fix: 23 passed.

Coverage added, in packages/aws-sigv4/test/sigv4.spec.ts:

  • 600 virtual seconds moves the stamp exactly 600 seconds (the issue's own measurement).
  • A default manualClock() signs 19700101T000000Z — the ~20,670-day skew becomes something
    a test can see and assert rather than something it silently suffers.
  • The signature itself changes with the clock, and so does the credential scope date — proof
    the stamp is inside the signed canonical request, not cosmetic.
  • The two wire-unchanged pins above.
  • End-to-end through the public API: a stitch() with clock: manualClock(...) and
    mockAdapter, asserting the x-amz-date the transport actually receives, then
    clock.advance(600_000) and asserting it moved ten virtual minutes. This is the seam the
    issue asked for, exercised the way a user would.

Bundle size — nothing moved

git diff origin/main -- packages/core is empty. Not one byte was spent in core; the
AuthContext.clock field this needs already existed.

scenario min+gzip budget headroom
stitchapi — whole entry 24,628 B 24,678 B 50 B ✓
import { stitch } 21,982 B 22,016 B 34 B
stitchapi/auth — whole surface 5,309 B 5,478 B 169 B ✓

Identical to main — same inputs, since core is untouched. @stitchapi/aws-sigv4 is a
companion and is not part of core's gated entry. The bundle-advertised-size drift tether is
in sync (the pre-push yakir run reports 9 tethers, 0 drift).

Docs — §2's second clause

§2's second ask was to "audit which time-driven features read the injected clock and which read
Date.now(), and state the answer in the testing guide."
#664 already built that table, so
this PR does the remaining concrete thing: moves the SigV4 row from wall-clock to virtual.

The callout below the table still reads "the four core wall-clock rows" and is now more
accurate — before this there were five wall-clock rows, four of them core.

Also added: a Signing time section on the aws-sigv4 integration page with the manualClock
example, and a note (in both places) that an unseeded manualClock() signs 19700101T000000Z,
which a real endpoint answers with RequestTimeTooSkewed — the same epoch-0 trap the guide
already documents for HTTP-date Retry-After.

One non-drive-by cleanup, called out

The new test context has the same no-op emit shape as the existing fakeCtx, which trips
@typescript-eslint/no-empty-function — a rule this package had baselined at count: 1.
Rather than raise the baseline, both contexts now share one non-empty no-op ((): void => undefined)
and the suppression is dropped via --prune-suppressions. The baseline file shrinks by 5
lines; nothing was added to it. (Per #646, this package is now linted.)

What remains open on #658

  • §1 — hooks.onRequest runs after signing. Not touched. Its ask is a docs line plus a
    "Better:" proposal to emit an info when an onRequest hook exceeds some duration threshold
    on an auth-carrying stitch. The threshold and whether to spend the bytes are maintainer calls.
  • §3 — a skew 403 counts as a circuit failure. Not touched. Its ask is an explicit either/or
    (a "client fault, don't count it" marker vs. documenting the exclusion), and it is entangled
    with the absent-flag rule in Surface.interpret that the issue tracks across three sightings.
  • §4 — smaller findings. Read, none actioned, none of them trivial-and-design-free:
    • refresh cannot see the response that triggered it — a public API change to
      AuthStrategy.refresh's signature. Out of scope by the "no half-baked API change" bar.
    • A breaker does not shed a burst already queued behind a throttle — the issue itself calls
      it "defensible, and worth documenting". A core-engine docs claim, unrelated to this package;
      landing it here would be a drive-by.
    • backoff.max defaults to 10 s — explicitly framed as the fourth silent-clamp in the
      pass, i.e. a pattern-level design decision, not a point fix.
    • A skew 403 is not retried by default — the issue states this is correct behaviour and
      worth keeping
      . Nothing to do.

Verification

All from the repo root, all passing: check:lint (36 packages), check:types, test
(1,477 core + 23 sigv4, whole workspace green), check:format, check:contract,
check:unknown-keys, check:types-d, check:size, check:changelog, check:docs-links,
check:exports:companions, check:exports. The pre-push hook's full verify sequence also ran
clean, including build-docs and the yakir tethers.

Refs #658

🤖 Generated with Claude Code

The signer stamped `x-amz-date` from `amzDateOf(new Date())`, so SigV4 could
not be tested on virtual time: 600 virtual seconds moved the shipped stamp 0
seconds, and a default `manualClock()` (which starts at epoch 0) still produced
a real-time stamp. Every test wanting to assert anything about signing time had
to inject its own clock-reading signer to measure it.

#664 put the stitch's resolved `clock` on `AuthContext` for `oauth2`; this is
the companion package taking the same seam. The signing timestamp is
control-flow time by ADR 0010's own definition — `x-amz-date` is inside the
string-to-sign and AWS refuses a stamp more than ~5 minutes out with
`RequestTimeTooSkewed` — so it belongs on the clock that already drives retry,
throttle, timeout, circuit and token freshness. It reads it through the
identical `ctx.clock?.now() ?? Date.now()` fallback core's `auth.ts` uses, so a
hand-built `AuthContext` in a custom strategy's unit test still type-checks.

Nothing changes on the wire. The engine threads `systemClock` unless a clock was
injected, and `systemClock.now()` IS `Date.now()`. Pinned by a test that signs
on the wall clock, reads back the instant it stamped, re-signs on a clock pinned
to that instant, and asserts the `Authorization` header is byte-identical, plus
a golden signature cross-checked against an independent SigV4 implementation
that reproduces the official `get-vanilla` vector. Payload hashing and the
`signBody` branches are untouched.

The mocking guide's clock map moves the SigV4 row from wall-clock to virtual,
and the integrations page gains a "Signing time" section. Both note that an
unseeded `manualClock()` signs `19700101T000000Z`.

The package's `no-empty-function` suppression is dropped rather than raised: the
two test contexts now share one non-empty no-op `emit`.

Refs #658 (§1 hooks.onRequest ordering and §3 skew-403 circuit accounting are
maintainer calls and remain open).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@rejifald
rejifald merged commit 252f350 into main Aug 5, 2026
12 checks passed
@rejifald
rejifald deleted the claude/sigv4-inject-clock-658 branch August 5, 2026 22:14
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant