Skip to content

feat(pam-access): verify X-Signature-256 / X-Timestamp / X-Nonce - #93

Merged
guimard merged 3 commits into
mainfrom
feat/pam-request-signing
Sep 6, 2026
Merged

guimard merged 3 commits into
mainfrom
feat/pam-request-signing

Conversation

@guimard

@guimard guimard commented Sep 6, 2026

Copy link
Copy Markdown
Member

Closes #81 (linagora/open-bastion#188). Rebased onto main; #94 follows.

The PAM/NSS client signs some of its calls to the portal when
request_signing_secret is set, and nothing on this side read the headers —
the replay-protection chain was a no-op that cost bandwidth and bought
nothing. This is the verifier, built against the wire format the issue
documents.

What it does

message = <timestamp>.<nonce>.<method>.<path>.<body>
HMAC-SHA256, key = the raw bytes of the shared secret

Four literal . separators, always present; a bodyless request signs the empty
string. <path> has no scheme, host or query. <body> is the raw bytes as
sent — the HMAC is computed in _checkCaller, before anything decodes and
re-encodes the JSON.

Checks run timestamp → HMAC → nonce:

  1. Timestamp against pamAccessRequestSigningWindow (300 s), first — the
    cheapest check, and it bounds both how long a captured request stays
    replayable and how big the nonce store has to be.
  2. HMAC, compared in constant time.
  3. Nonce, single-use, last — it is the only step that writes. Claiming
    before verifying would let an unauthenticated caller fill the shared
    session backend with one record per request, and burn the nonce of a
    request it had captured before the legitimate retry arrived. One session
    per nonce keyed on its digest, expiring with the window, so it holds across
    workers and nodes.

One gate for all six endpoints, via _checkCaller.

Rollout — required is not deployable yet

The gate covers all six /pam/* endpoints. The client signs two:
/pam/verify and /pam/authorize (add_signing_headers has exactly those two
call sites, ob_client.c:726 and :1137). /pam/heartbeat says so in its own
comment — "no Authorization header, no request signing", it authenticates by
the refresh_token in its body. /pam/bastion-cert sends Bearer only
(ob-cert-daemon.c), and nothing calls /pam/userinfo.

Heartbeat is the dangerous one: it is how every enrolled host renews its access
token, so required would break nothing at the moment it is switched on and
take the whole fleet down hours later, together, when the tokens still in hand
expire.

Mode Unsigned request Bad signature
off (default) served served (headers ignored)
optional served refused
required refused refused

So the order is: deploy optional, roll the secret out, wait for the client
to sign heartbeat and bastion-cert
, then required. optional is a useful
destination in its own right — it already refuses a bad signature on the two
endpoints that consume credentials.

A partially signed request is refused as malformed in both modes: it is not the
"old client that does not sign" case optional exists for. A configured mode
with an empty secret refuses everything rather than waving it through, and an
unrecognised mode value is treated as required with a once-per-worker
warning.

Documented in the README, with the sizing that matters once heartbeat is
signed: two store round-trips per signed request, one resident record per nonce
per window, and heartbeat beats on a timer whether anyone logs in or not.

Known limit

Read-then-create on the nonce cache is not atomic — the LLNG store has no
insert-if-absent, the same wall #87/#88/#95 ran into. Two replays landing in
the same instant can both pass. That is a far smaller target than the 300 s
window this closes, and the comment says so rather than implying otherwise.

Tests

t/17-PamAccess-RequestSigning.t, 44 assertions, including the issue's worked
example verbatim: all three modes, the timestamp window in both directions,
nonce replay, body tampering, a signature for a different path not
transferring, four shapes of malformed header, the empty-secret refusal, the
unknown-mode fallback, and that a bad signature does not burn the nonce.
pam-access 959 total, ssh-ca 610, green.

https://claude.ai/code/session_01GfBG36HfzjGy8W9rJQbxBL

@guimard guimard left a comment

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Found a blocker while reviewing: the nonce is claimed before the HMAC is verified. _claimNonce writes a PAMNONCE record into the shared session backend for any request whose headers parse and whose timestamp is inside the window — both attacker-controlled — so an unauthenticated caller can fill globalStorage with one record per request without knowing the secret. That's a storage-exhaustion vector against the exact shared store this PR leans on, and the PR body's own "checks in order of cost" argument points the other way: the HMAC (one SHA-256 over a short message) is cheaper than the two storage roundtrips _claimNonce makes.

Confirmed empirically: bad signature with nonce N → 403 invalid signature; then the correct signature with the same N → 403 Request nonce refused (N was burned by the bad-signature request).

Suggested fix: order timestamp → HMAC → nonce claim.

Minor: an unknown mode value (typo, e.g. optionnal) silently behaves as required — fail-closed is acceptable, but a one-line warning would help.

Everything else checks out: wire format pinned against the issue's worked example, constant-time compare, fail-closed on empty secret, hashStore => 0 consistent with the #37 lesson, nonce expiry arithmetic correct (reaped at t0 + window).

guimard added a commit that referenced this pull request Sep 6, 2026
_claimNonce writes a PAMNONCE record into the shared session backend. It ran
before the signature was checked, so every input it depended on — the header
set and the timestamp — was attacker-controlled: an unauthenticated caller
could create one storage record per request, against the very store this
protection leans on, and could burn the nonce of a captured request before
its legitimate retry arrived.

Order is now timestamp -> HMAC -> nonce claim. The cost argument the original
comment made points this way too: one SHA-256 over a short message is cheaper
than the two storage roundtrips _claimNonce makes.

Also warn once when pamAccessRequestSigningMode holds a value that is not
off/optional/required. Falling through to 'required' is the right default,
but a typo silently selecting the strictest mode is a production surprise.

t/17 pins both: a forged signature no longer invalidates the nonce, and a
misspelled mode still refuses unsigned requests.

Reported in review of #93.

Claude-Session: https://claude.ai/code/session_01GfBG36HfzjGy8W9rJQbxBL
@guimard

guimard commented Sep 6, 2026

Copy link
Copy Markdown
Member Author

You are right, and the PR body's own cost argument was the tell. Fixed in c8a5d39: the order is now timestamp → HMAC → nonce claim.

_claimNonce is the only step that writes, so it must be the last one, and it must not run for a request whose signature does not verify. Both consequences you named are gone: no storage record for an unauthenticated caller, and a captured request's nonce is no longer burnable by a forgery.

t/17 pins it — a wrong signature on nonce N is refused as a signature problem, and N is then still accepted for the correctly signed request. Verified that assertion fails on the parent commit.

Minor also done: an unrecognised mode value still falls through to required (fail closed), but now with a once-per-worker warning naming the bad value and the three valid ones. Test 43 pins that optionnal does not wave unsigned requests through.

README updated to state the order and why the nonce claim is last.

https://claude.ai/code/session_01GfBG36HfzjGy8W9rJQbxBL

open-bastion's PAM/NSS client has been signing every call to the portal when
request_signing_secret is set, and nothing on this side ever read the headers.
The client signed, nothing checked: the replay-protection chain was a no-op
that cost bandwidth and bought nothing. This is the missing verifier, built
against the wire format settled by open-bastion#188.

  message = <timestamp>.<nonce>.<method>.<path>.<body>
  HMAC-SHA256, key = the raw bytes of the shared secret

Checks run in order of cost — timestamp, then nonce, then HMAC — so a stale
request never makes the portal hash anything. The nonce is single-use, cached
one session per nonce in the shared backend and expiring with the window, so
it holds across workers and nodes. The HMAC is compared in constant time and
computed over the raw body bytes, before anything decodes the JSON.

Three modes, because turning this on is a breaking change for any fleet where
some hosts have the secret and some do not:

  off       (default) headers ignored.
  optional  a signed request must verify; an unsigned one passes. Run this
            while rolling the secret out.
  required  headers mandatory.

`optional` waives the requirement to sign, never the requirement to sign
correctly: a bad signature is refused in every mode but off. A configured mode
with an empty secret refuses everything rather than waving it through.

One gate for all six endpoints, via _checkCaller.

Closes #81

Claude-Session: https://claude.ai/code/session_01GfBG36HfzjGy8W9rJQbxBL
_claimNonce writes a PAMNONCE record into the shared session backend. It ran
before the signature was checked, so every input it depended on — the header
set and the timestamp — was attacker-controlled: an unauthenticated caller
could create one storage record per request, against the very store this
protection leans on, and could burn the nonce of a captured request before
its legitimate retry arrived.

Order is now timestamp -> HMAC -> nonce claim. The cost argument the original
comment made points this way too: one SHA-256 over a short message is cheaper
than the two storage roundtrips _claimNonce makes.

Also warn once when pamAccessRequestSigningMode holds a value that is not
off/optional/required. Falling through to 'required' is the right default,
but a typo silently selecting the strictest mode is a production surprise.

t/17 pins both: a forged signature no longer invalidates the nonce, and a
misspelled mode still refuses unsigned requests.

Reported in review of #93.

Claude-Session: https://claude.ai/code/session_01GfBG36HfzjGy8W9rJQbxBL
@guimard
guimard force-pushed the feat/pam-request-signing branch from 504b099 to 1d5079f Compare September 6, 2026 15:38
@guimard
guimard changed the base branch from fix/pam-rp-allowlist to main September 6, 2026 15:38
The rollout guide said "deploy optional, roll the secret out, switch to
required". Following it breaks the fleet: the gate covers all six /pam/*
endpoints and the Open Bastion client signs two of them.

Checked in the client sources: add_signing_headers has exactly two call sites,
/pam/verify (ob_client.c:726) and /pam/authorize (ob_client.c:1137).
/pam/heartbeat says so in its own comment -- "no Authorization header, no
request signing", it authenticates by the refresh_token in its body.
/pam/bastion-cert sends Content-Type and Bearer only (ob-cert-daemon.c), and
nothing calls /pam/userinfo.

Heartbeat is the bad one: it is how every enrolled host renews its access
token, so `required` breaks nothing at the moment it is switched on and takes
the whole fleet down hours later, together, when the tokens still in hand
expire.

So: state what the client signs today, make "wait for the client to sign
heartbeat and bastion-cert" an explicit step of the rollout rather than an
assumption, and say that `optional` is a useful destination in its own right
-- it already refuses a bad signature on the two endpoints that consume
credentials. Also note the sizing that becomes relevant once heartbeat is
signed: two store round-trips per signed request, one resident record per
nonce per window, and heartbeat beats on a timer whether anyone logs in or
not.

No code change; the verifier was never the problem.

Claude-Session: https://claude.ai/code/session_01GfBG36HfzjGy8W9rJQbxBL
@guimard
guimard merged commit 116f876 into main Sep 6, 2026
44 of 88 checks passed
@guimard
guimard deleted the feat/pam-request-signing branch September 6, 2026 16:00
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.

pam-access: no server-side verifier for X-Signature-256 / X-Nonce (open-bastion#188)

1 participant