Skip to content

feat: implement Web Bot Auth (httpsig-protocol-00) - #4

Merged
Xe merged 32 commits into
developfrom
Xe/base-implementation
Sep 28, 2026
Merged

Xe merged 32 commits into
developfrom
Xe/base-implementation

Conversation

@Xe

@Xe Xe commented Sep 28, 2026 •

Copy link
Copy Markdown
Contributor

Summary

Implements Web Bot Auth for Anubis, following draft-ietf-webbotauth-httpsig-protocol-00 on top of RFC 9421 via github.com/yaronf/httpsign. The repo previously held only an empty package.

The spec drafts in docs/RFC/ were out of date. The architecture and directory drafts were superseded by the working group document, so they are replaced and the plan (docs/plans/base-implementation.md) follows the new draft.

Library

  • Verifier (for Anubis): checks each web-bot-auth signature independently and returns one of three outcomes: verified, invalid, or unverified (Appendix C.1).

    • Required: created, expires, keyid, the tag, and coverage of @authority or @target-uri.
    • Finds the Signature-Agent member through the covered component's ;key=, never by label. Accepts the legacy string form.
    • Keys are looked up by (identifier, keyid). Static keys give only a thumbprint identity.
    • A per-agent replay store; Accept-Signature challenges; 400, 403, and 429 helpers.
  • Fetcher: resolves Signature-Agent origins to key directories. The URL comes from the client, so fetching is hardened (Section 6.7):

    • https only, no proxy, and a dial-time block of private, loopback, and other special addresses (so DNS rebinding cannot bypass it);
    • no redirects, 200 only;
    • size, time, and concurrency limits.

    Caching uses HTTP semantics. A failed fetch never evicts a cached directory. Checking directory response signatures is optional.

  • Signer and Transport: Ed25519, ECDSA P-256/P-384, and RSA-PSS keys. Each signature gets a thumbprint keyid, a lifetime of at most 24 hours, and a nonce.

  • NewDirectoryHandler: serves the public keys, signed per host, but only for the configured Authorities. Other hosts get 421.

  • SignStaticDirectory and key helpers: GenerateKey, PEM helpers, PublicJWK, MarshalDirectory.

cmd/samesame (urfave/cli)

  • keygen, keyid, and directory.
  • directory --sign-for HOST prints nginx and Caddy config for a signed static directory.
  • serve watches a folder of .pem keys and serves the directory, reloading when the files change.
  • --version reads the version the Go toolchain stamps from git.

Packaging and CI

  • Distroless multi-arch Dockerfile and docker-bake.hcl. .dockerignore keeps var/ and all *.pem files out of the build context.

  • .github/workflows/docker.yml publishes to ghcr.io/techarohq/samesame:

    • pull requests build only;
    • main and develop push branch and sha- tags;
    • releases push vX.Y.Z, vX.Y, and latest.

    Release images are built on workflow_run after Release, because semantic-release tags with GITHUB_TOKEN, which does not trigger other workflows.

  • ci.yml drops oldstable, because the module needs Go 1.27, and fixes the Codecov condition, which never matched.

Spec notes

  • The old -04 test vector A.2.2 was stale. The -00 Appendix E vectors all verify, including RSA-PSS.
  • The Section 5.5.1 example directory in -00 uses a kid that is not the key thumbprint. The parser correctly rejects it.

Cloudflare interop (tested live)

  • crawltest.com accepts the headers' format.
  • Cloudflare's research verifier rejects the "signature-agent";key= dictionary component, which -00 requires. It accepts the legacy string form.
  • Cloudflare's own directory signs ("@authority";req) without content-digest, so its key is dropped only when VerifyDirectorySignatures is on.
  • There is no compat flag yet. That can follow in its own PR.

Test plan

  • go test ./... and go vet ./... pass, 3 runs in a row with no flakes. Coverage: 92.7% for the library, 81% for the CLI.
  • All Appendix E vectors verify: E.1.1, E.1.2, E.2.1, E.2.2, and E.2.3.
  • End-to-end test over TLS: signer to directory handler to fetcher to verifier.
  • Fuzzed Signature-Agent parsing and verification, about 20M runs in total.
  • Every security check was disabled one at a time, and a test failed each time. This found and fixed several weak tests.
  • /code-review high findings fixed: directory host binding, a nonce-store DoS, a fetcher memory leak and stalls, and weak key acceptance.
  • The generated nginx (Docker) and Caddy 2.11 configs were served for real and verified by the fetcher, with compression enabled elsewhere on the site.
  • samesame serve smoke-tested as a binary and as a container, including key rotation, 503 when no keys remain, and SIGTERM.
  • docker buildx bake builds amd64 and arm64. actionlint passes.
  • The Docker workflow has not run on GitHub yet. The GHCR package may need linking to the repo on the first push.

Assisted-by: Claude Opus 5.5 via Claude Code

Xe added 30 commits September 28, 2026 13:03
Before building on github.com/yaronf/httpsign, confirm it covers what
the web-bot-auth drafts need: pre-verification access to keyid, nonce
and tag, dictionary member components (signature-agent;key=), and
response signatures bound to the request with the ;req flag.

The architecture draft -04 A.2.2 vector is stale: its headers use the
dictionary form of Signature-Agent, but the signature bytes only
verify against the older string form. The test pins the form that
verifies and separately checks httpsign against a hand-built RFC 9421
Section 2.1.2 signature base.

Requires Go 1.27, as httpsign does.

Assisted-by: Claude Opus 5.5 via Claude Code
Signed-off-by: Xe Iaso <me@xeiaso.net>
Records the scope (verifier, signer, directory handler), the choice
to wrap github.com/yaronf/httpsign, the package layout, and the test
and verification strategy.

Assisted-by: Claude Opus 5.5 via Claude Code
Signed-off-by: Xe Iaso <me@xeiaso.net>
draft-meunier-web-bot-auth-architecture-04 and
draft-meunier-http-message-signatures-directory-04 were replaced,
via -05 and two renames, by the working group document
draft-ietf-webbotauth-httpsig-protocol-00 (2026-09-01), which now
defines the key directory and Signature-Agent itself.

The registry draft moves from -01 to -03. The glossary draft stays:
-01 is still its latest revision and it has no successor.

Assisted-by: Claude Opus 5.5 via Claude Code
Signed-off-by: Xe Iaso <me@xeiaso.net>
The working group draft changes several things the plan relied on:
Signature-Agent is https-only with origin-form members and a type
parameter, members are located by ;key= rather than label, the
identifier is the resolved well-known URL keyed with keyid, directory
response signatures are optional for verifiers and now cover
content-digest, and discovery forbids redirects and cache eviction on
failure. Test vectors move to Appendix E.

Assisted-by: Claude Opus 5.5 via Claude Code
Signed-off-by: Xe Iaso <me@xeiaso.net>
Assisted-by: Claude Opus 5.5 via Claude Code
Signed-off-by: Xe Iaso <me@xeiaso.net>
Replace the -04 architecture vectors with Appendix E.2 from the
working group draft: the dictionary and legacy string forms of
Signature-Agent (member key agent2 under label sig2), and the E.2.3
signed directory response covering "@authority";req and
content-digest. All of them verify through httpsign.

Assisted-by: Claude Opus 5.5 via Claude Code
Signed-off-by: Xe Iaso <me@xeiaso.net>
ParseDirectory reads a JWKS key directory and drops bad keys one at a
time rather than rejecting the whole document: malformed keys, keys
with private material, non-signing keys, keys whose kid is not their
RFC 7638/8037 thumbprint (httpsig-protocol-00 Section 5.5), and the
published RFC 9421 test keys (Section 6.8) unless a test opts in. The
directory as a whole is rejected when it has more keys than the
configured cap (Section 6.7).

The Section 5.5.1 example directory in the draft uses a kid that is
not the key's thumbprint, so it is dropped; the tests pin that.

Assisted-by: Claude Opus 5.5 via Claude Code
Signed-off-by: Xe Iaso <me@xeiaso.net>
Follows httpsig-protocol-00 Section 5.2.1 and 5.5. Dictionary members
default to type=directory and must be the ASCII serialization of an
https origin (a "/" path is tolerated); the verifier resolves the
well-known directory URL at that origin, which becomes the member's
identifier. Members with other schemes, paths, queries, jwks_uri,
cimd, or unknown types are kept but marked ignored with a reason.

The deprecated single String Item form is accepted, as the draft
allows, and exposed as one unkeyed member so the verifier can match
it to an unkeyed "signature-agent" component.

Assisted-by: Claude Opus 5.5 via Claude Code
Signed-off-by: Xe Iaso <me@xeiaso.net>
Signer adds Signature-Agent, Signature-Input, and Signature to a
request, covering @authority and the agent's Signature-Agent member
with keyid set to the key thumbprint and tag=web-bot-auth. It supports
Ed25519, ECDSA P-256/P-384, and RSA-PSS SHA-512 keys; HMAC is not
offered because httpsig-protocol-00 Section 6.4 forbids shared
secrets.

An agent origin is required because a signed request MUST carry
Signature-Agent (Section 4.3). Signatures default to a one hour
lifetime, capped at 24 hours (Section 5.2), with a random 64-byte
nonce. The signature label and Signature-Agent member key are
separate settings, matching the Appendix E vectors. Existing
signatures and members from other signers are preserved, and a
failed Sign leaves the request unchanged.

Transport wraps an http.RoundTripper and signs a clone of each
request.

Assisted-by: Claude Opus 5.5 via Claude Code
Signed-off-by: Xe Iaso <me@xeiaso.net>
Verifier implements the httpsig-protocol-00 validation procedure
(Section 5.4) on top of httpsign. Each web-bot-auth signature is
checked independently: created, expires, and keyid are required,
@authority or @target-uri must be covered, and the validity window is
capped by policy. The covered Signature-Agent member is found by its
;key= parameter, never by signature label (Section 6.6.1), and the
legacy string form is matched to an unkeyed component.

Keys are looked up by (identifier, keyid) through a KeyResolver. A
key from the resolved directory attributes the request to the
well-known URL; a key held out of band verifies it with only a
thumbprint identity, including after discovery failure (Sections
4.3 and 6.10).

Results follow the three outcomes of Appendix C.1. Errors map to
400, 403, and 429, and WriteChallenge emits Accept-Signature
(Section 5.3). Nonces are recorded only after a signature verifies,
so a forger cannot burn a real agent's nonce, and a nonce store
failure yields unverified rather than a false claim of replay
protection (Appendix C.6).

Assisted-by: Claude Opus 5.5 via Claude Code
Signed-off-by: Xe Iaso <me@xeiaso.net>
NewDirectoryHandler serves the public halves of an agent's keys at
the well-known URI with kid set to each key's thumbprint. Every
response carries Content-Digest and one signature per key over
("@authority";req "content-digest") tagged
http-message-signatures-directory (httpsig-protocol-00 Appendix
B.1), with a long signature lifetime per Appendix C.8. It supports
HEAD, ETag revalidation, and permissive CORS (Appendix C.11).

Fetcher resolves Signature-Agent identifiers for the Verifier. Since
the URL comes from the request, fetches are hardened per Section
6.7: https only, no proxy, a dial-time check that rejects loopback,
private, link-local, CGNAT, NAT64, 6to4 and other special ranges
(so DNS rebinding cannot bypass it), a body cap after decoding, a
timeout, and an optional host allow-list. Redirects are not followed
and anything but 200 is a discovery failure (Section 5.5).

Caching follows Appendix C.4 and C.5: Cache-Control and Expires set
the TTL within bounds, stale entries are revalidated with
conditional requests, and failures back off exponentially with
jitter, honor Retry-After, and never exceed five minutes. A failed
fetch never evicts a cached directory, while a successful one always
replaces it (Section 6.10). Concurrent fetches are coalesced and
limited per origin (Appendix C.3).

Directory response signature checking is opt-in, since Appendix B
lets verifiers use directly resolved keys without it.

Assisted-by: Claude Opus 5.5 via Claude Code
Signed-off-by: Xe Iaso <me@xeiaso.net>
An agent signs through Signer.Transport, the origin verifies with a
Verifier whose Fetcher discovers the key from a DirectoryHandler with
directory response signatures checked, all over TLS. Covers a
verified request, an unsigned one getting Accept-Signature, a key
missing from the directory, and a replay getting 429.

Assisted-by: Claude Opus 5.5 via Claude Code
Signed-off-by: Xe Iaso <me@xeiaso.net>
go.mod now requires Go 1.27, which oldstable cannot build. The
Codecov step checked for a 'latest' matrix entry that never existed,
so coverage was never uploaded.

Assisted-by: Claude Opus 5.5 via Claude Code
Signed-off-by: Xe Iaso <me@xeiaso.net>
Assisted-by: Claude Opus 5.5 via Claude Code
Signed-off-by: Xe Iaso <me@xeiaso.net>
GenerateKey creates Ed25519, ECDSA P-256/P-384, and RSA-PSS keys.
MarshalPrivateKeyPEM and ParsePrivateKeyPEM move them to and from
PEM (PKCS #8, plus SEC 1 and PKCS #1 on input). PublicJWK and
MarshalDirectory build the published JWKS with kid set to the
thumbprint and use set to sig.

NewDirectoryHandler now uses MarshalDirectory, so a directory
generated offline is byte for byte what the handler serves.

Assisted-by: Claude Opus 5.5 via Claude Code
Signed-off-by: Xe Iaso <me@xeiaso.net>
A urfave/cli app with three commands:

- keygen writes a new private key with mode 0600, refusing to
  overwrite without --force, and prints its keyid
- directory prints the key directory JSON for one or more private or
  public keys, for publishing and rotation
- keyid prints the thumbprint of existing keys

Assisted-by: Claude Opus 5.5 via Claude Code
Signed-off-by: Xe Iaso <me@xeiaso.net>
Directory parsing only rejected keys with a "d" member, so symmetric
oct keys, unsupported curves, and weak RSA keys were accepted as
valid. The oct and curve cases then surfaced only at verify time as
an unknown key, and a weak RSA key was actually used to verify.

NewKey now validates a JWK once: no symmetric or private keys, a
supported type and curve, and RSA moduli of at least 2048 bits
(also enforced when signing). It caches the verifier constructor, so
the verifier no longer exports the raw key on every request, and
static keys, the signer, and custom KeyResolvers share the same
path. Keys built by hand still verify through a fallback.

Assisted-by: Claude Opus 5.5 via Claude Code
Signed-off-by: Xe Iaso <me@xeiaso.net>
- Replace the per-origin semaphore map, which grew by one entry per
  host forever, with one global MaxConcurrentFetches limit. Fetches
  of an identifier are already coalesced, and a directory identifier
  is one per origin, so each origin still has at most one fetch in
  flight (Appendix C.3).
- Use singleflight DoChan so a caller whose context is cancelled
  returns at once, while the shared fetch continues and fills the
  cache for everyone else.
- Check directory response signature expiry against the Fetcher's
  clock with a configurable ClockSkew, instead of mixing the injected
  clock for created with httpsign's wall clock for expires.
- Raise the response header limit to 64 KiB. A signed directory has
  one Signature and Signature-Input per key, and 32 RSA keys exceed
  16 KiB.
- Reuse the verifier cached on each Key when checking bindings.

Assisted-by: Claude Opus 5.5 via Claude Code
Signed-off-by: Xe Iaso <me@xeiaso.net>
The directory handler signed "@authority";req for whatever Host a
client sent, so anyone could fetch the directory with Host set to
their own domain and get valid Appendix B.1 proofs binding the bot's
keys to it. A verifier checking directory signatures would then
accept those keys as bound to the attacker's domain.

DirectoryHandlerOptions.Authorities is now required. Requests for
other hosts get 421 Misdirected Request and no signatures, and
signatures are made for the canonical lowercase host.

Every unauthenticated GET also cost one private key signature per
key. Signatures are now cached per authority for up to an hour, and
the cache is bounded by the Authorities list.

BREAKING CHANGE: NewDirectoryHandler returns an error unless
DirectoryHandlerOptions.Authorities lists the hosts to serve.

Assisted-by: Claude Opus 5.5 via Claude Code
Signed-off-by: Xe Iaso <me@xeiaso.net>
MemoryNonceStore had one global capacity and refused new nonces when
full, which the verifier reports as unverified. Anyone with their own
key directory could fill it with validly signed requests carrying
24 hour expiries and make every other agent unverifiable for a day.

Nonces are now scoped by the agent's identifier, or keyid for static
keys, so agents cannot collide. When the store is full it evicts the
soonest-expiring nonce of the scope holding the most entries, found
in constant time, so a flood degrades replay protection for the
heaviest scope, usually the flooder, instead of breaking
verification for everyone. Evicted counts these events, and the
expiry heap is compacted so a sustained flood cannot grow memory
past twice the capacity.

Result.NonceChecked reports whether a nonce store actually checked
the signature's nonce.

BREAKING CHANGE: NonceStore.CheckAndRecord takes a scope argument,
and MemoryNonceStore no longer returns ErrNonceStoreFull.

Assisted-by: Claude Opus 5.5 via Claude Code
Signed-off-by: Xe Iaso <me@xeiaso.net>
readPublicKey only recognized PKIX "PUBLIC KEY" blocks, so an "RSA
PUBLIC KEY" file fell through to the private key parser and failed
with a misleading error, even though the help text says public keys
are accepted.

Assisted-by: Claude Opus 5.5 via Claude Code
Signed-off-by: Xe Iaso <me@xeiaso.net>
SignStaticDirectory returns the directory body plus the Content-Type,
Cache-Control, Content-Digest, Signature-Input, and Signature headers
for one authority. Directory response signatures cover only
"@authority";req and content-digest, so they are the same for every
request and can be configured as static headers in a file server.
It reuses the handler's signing path, so static and live directories
are identical.

Assisted-by: Claude Opus 5.5 via Claude Code
Signed-off-by: Xe Iaso <me@xeiaso.net>
`samesame directory --sign-for HOST --out FILE` writes the directory
byte for byte and prints an nginx location block and a Caddy handle
block that serve it with the right media type, Cache-Control,
Content-Digest, and one directory response signature per key.
--format picks nginx, caddy, or both; --lifetime and --max-age tune
the signatures and caching. Hosts are lowercased to match the
authority the signatures bind.

The blocks disable compression for the file, since Content-Digest
covers the exact bytes sent. Both were checked against real servers:
Caddy 2.11 with site-wide encode, and nginx in Docker with gzip on
for every type. The Fetcher verified all keys from each, and dropped
them when the same nginx was reached under a host it was not signed
for.

Assisted-by: Claude Opus 5.5 via Claude Code
Signed-off-by: Xe Iaso <me@xeiaso.net>
The directory is ignored except for its .gitignore, so private keys
kept there for local testing never get committed.

Assisted-by: Claude Opus 5.5 via Claude Code
Signed-off-by: Xe Iaso <me@xeiaso.net>
`samesame serve --keys DIR --authority HOST` serves the signed key
directory for every .pem private key in DIR and watches the folder
with fsnotify, so adding or deleting a key file rotates keys without
a restart. It watches the folder rather than the files, so renames
into place and symlink swaps are seen, batches bursts of events, and
also reloads on a poll interval in case an event is missed.

If any .pem fails to parse, as when a file is caught mid-write, the
previous keys stay served, so a key is never dropped by accident.
Deleting every key serves 503. The handler is only rebuilt when the
set of keys changes, which keeps its signature cache.

Assisted-by: Claude Opus 5.5 via Claude Code
Signed-off-by: Xe Iaso <me@xeiaso.net>
samesame --version reads runtime/debug.ReadBuildInfo: the module
version for go install ...@Version, or the pseudo-version Go derives
from git tags and the commit for builds in a checkout. No -ldflags
are needed.

Assisted-by: Claude Opus 5.5 via Claude Code
Signed-off-by: Xe Iaso <me@xeiaso.net>
A multi-stage Dockerfile cross-compiles on the build platform, so
linux/amd64 and linux/arm64 build without emulation, into a
distroless static image that runs as a non-root user. The binary's
version comes from git, so .git stays in the build context while
.dockerignore keeps var/ and every *.pem out of it.

docker-bake.hcl builds the multi-platform image, or a local one with
`docker buildx bake local`, and inherits its tags from a
docker-metadata-action target that CI replaces.

Assisted-by: Claude Opus 5.5 via Claude Code
Signed-off-by: Xe Iaso <me@xeiaso.net>
Pull requests build the image without pushing. Pushes to main and
develop publish branch and sha tags. Releases publish vX.Y.Z, vX.Y,
and latest.

semantic-release tags with GITHUB_TOKEN, which does not trigger other
workflows, so release images are built on workflow_run after the
Release workflow succeeds, from the newest tag. The build uses the
checkout rather than a git context so the Go toolchain can stamp the
version from .git.

Assisted-by: Claude Opus 5.5 via Claude Code
Signed-off-by: Xe Iaso <me@xeiaso.net>
The pre-commit hook called $HOME/go/bin/goimports, which only exists
if it was installed by hand, so commits failed on a clean machine.
go.mod already declares goimports as a tool, and the fmt script uses
go tool goimports.

Assisted-by: Claude Opus 5.5 via Claude Code
Signed-off-by: Xe Iaso <me@xeiaso.net>
Printed output from the CLI now returns its write errors: the server
config writer remembers the first error across its many writes, and
keygen, keyid, and the directory separators check theirs. Close calls
that are only cleanup after another error, or after a response body
is done, discard their error explicitly, as do test handlers.

Assisted-by: Claude Opus 5.5 via Claude Code
Signed-off-by: Xe Iaso <me@xeiaso.net>
Xe added 2 commits September 28, 2026 14:23
golangci-lint-action v6 installs golangci-lint v1, whose latest
release is built with Go 1.24 and refuses to lint a Go 1.27 module.
Use action v9 with golangci-lint pinned to v2.14.0, the version that
passes locally.

Assisted-by: Claude Opus 5.5 via Claude Code
Signed-off-by: Xe Iaso <me@xeiaso.net>
TestMemoryNonceStoreManyScopes required 200k inserts to finish in
five seconds. Under -race in CI they took 5.6s, about 23 times the
normal 0.24s, so the test was flaky while still loose enough to miss
regressions.

It now compares inserts spread over one scope each against inserts
over eight scopes. Constant-time eviction keeps the ratio near 1.2x;
a linear scan for the largest scope makes it about 70x. The race
detector slows both sides equally, so the 10x limit holds under it.

Assisted-by: Claude Opus 5.5 via Claude Code
Signed-off-by: Xe Iaso <me@xeiaso.net>
@Xe
Xe merged commit c5125ca into develop Sep 28, 2026
3 checks passed
Xe added a commit that referenced this pull request Sep 28, 2026
* fix: remove boilerplate code

Signed-off-by: Xe Iaso <me@xeiaso.net>

* ci: do testing on develop too

Signed-off-by: Xe Iaso <me@xeiaso.net>

* docs: add relevant spec documents that will be consulted

Signed-off-by: Xe Iaso <me@xeiaso.net>

* feat: implement Web Bot Auth (httpsig-protocol-00) (#4)

* test: validate httpsign against web-bot-auth spec requirements

Before building on github.com/yaronf/httpsign, confirm it covers what
the web-bot-auth drafts need: pre-verification access to keyid, nonce
and tag, dictionary member components (signature-agent;key=), and
response signatures bound to the request with the ;req flag.

The architecture draft -04 A.2.2 vector is stale: its headers use the
dictionary form of Signature-Agent, but the signature bytes only
verify against the older string form. The test pins the form that
verifies and separately checks httpsign against a hand-built RFC 9421
Section 2.1.2 signature base.

Requires Go 1.27, as httpsign does.

Assisted-by: Claude Opus 5.5 via Claude Code
Signed-off-by: Xe Iaso <me@xeiaso.net>

* docs: add base implementation plan for web bot auth

Records the scope (verifier, signer, directory handler), the choice
to wrap github.com/yaronf/httpsign, the package layout, and the test
and verification strategy.

Assisted-by: Claude Opus 5.5 via Claude Code
Signed-off-by: Xe Iaso <me@xeiaso.net>

* docs: replace superseded web bot auth drafts with current versions

draft-meunier-web-bot-auth-architecture-04 and
draft-meunier-http-message-signatures-directory-04 were replaced,
via -05 and two renames, by the working group document
draft-ietf-webbotauth-httpsig-protocol-00 (2026-09-01), which now
defines the key directory and Signature-Agent itself.

The registry draft moves from -01 to -03. The glossary draft stays:
-01 is still its latest revision and it has no successor.

Assisted-by: Claude Opus 5.5 via Claude Code
Signed-off-by: Xe Iaso <me@xeiaso.net>

* docs: update implementation plan for httpsig-protocol-00

The working group draft changes several things the plan relied on:
Signature-Agent is https-only with origin-form members and a type
parameter, members are located by ;key= rather than label, the
identifier is the resolved well-known URL keyed with keyid, directory
response signatures are optional for verifiers and now cover
content-digest, and discovery forbids redirects and cache eviction on
failure. Test vectors move to Appendix E.

Assisted-by: Claude Opus 5.5 via Claude Code
Signed-off-by: Xe Iaso <me@xeiaso.net>

* feat: add web bot auth protocol constants

Assisted-by: Claude Opus 5.5 via Claude Code
Signed-off-by: Xe Iaso <me@xeiaso.net>

* test: check httpsign against httpsig-protocol-00 vectors

Replace the -04 architecture vectors with Appendix E.2 from the
working group draft: the dictionary and legacy string forms of
Signature-Agent (member key agent2 under label sig2), and the E.2.3
signed directory response covering "@authority";req and
content-digest. All of them verify through httpsign.

Assisted-by: Claude Opus 5.5 via Claude Code
Signed-off-by: Xe Iaso <me@xeiaso.net>

* feat: parse and validate web bot auth key directories

ParseDirectory reads a JWKS key directory and drops bad keys one at a
time rather than rejecting the whole document: malformed keys, keys
with private material, non-signing keys, keys whose kid is not their
RFC 7638/8037 thumbprint (httpsig-protocol-00 Section 5.5), and the
published RFC 9421 test keys (Section 6.8) unless a test opts in. The
directory as a whole is rejected when it has more keys than the
configured cap (Section 6.7).

The Section 5.5.1 example directory in the draft uses a kid that is
not the key's thumbprint, so it is dropped; the tests pin that.

Assisted-by: Claude Opus 5.5 via Claude Code
Signed-off-by: Xe Iaso <me@xeiaso.net>

* feat: parse the Signature-Agent header

Follows httpsig-protocol-00 Section 5.2.1 and 5.5. Dictionary members
default to type=directory and must be the ASCII serialization of an
https origin (a "/" path is tolerated); the verifier resolves the
well-known directory URL at that origin, which becomes the member's
identifier. Members with other schemes, paths, queries, jwks_uri,
cimd, or unknown types are kept but marked ignored with a reason.

The deprecated single String Item form is accepted, as the draft
allows, and exposed as one unkeyed member so the verifier can match
it to an unkeyed "signature-agent" component.

Assisted-by: Claude Opus 5.5 via Claude Code
Signed-off-by: Xe Iaso <me@xeiaso.net>

* feat: sign requests with web bot auth signatures

Signer adds Signature-Agent, Signature-Input, and Signature to a
request, covering @authority and the agent's Signature-Agent member
with keyid set to the key thumbprint and tag=web-bot-auth. It supports
Ed25519, ECDSA P-256/P-384, and RSA-PSS SHA-512 keys; HMAC is not
offered because httpsig-protocol-00 Section 6.4 forbids shared
secrets.

An agent origin is required because a signed request MUST carry
Signature-Agent (Section 4.3). Signatures default to a one hour
lifetime, capped at 24 hours (Section 5.2), with a random 64-byte
nonce. The signature label and Signature-Agent member key are
separate settings, matching the Appendix E vectors. Existing
signatures and members from other signers are preserved, and a
failed Sign leaves the request unchanged.

Transport wraps an http.RoundTripper and signs a clone of each
request.

Assisted-by: Claude Opus 5.5 via Claude Code
Signed-off-by: Xe Iaso <me@xeiaso.net>

* feat: verify web bot auth signatures

Verifier implements the httpsig-protocol-00 validation procedure
(Section 5.4) on top of httpsign. Each web-bot-auth signature is
checked independently: created, expires, and keyid are required,
@authority or @target-uri must be covered, and the validity window is
capped by policy. The covered Signature-Agent member is found by its
;key= parameter, never by signature label (Section 6.6.1), and the
legacy string form is matched to an unkeyed component.

Keys are looked up by (identifier, keyid) through a KeyResolver. A
key from the resolved directory attributes the request to the
well-known URL; a key held out of band verifies it with only a
thumbprint identity, including after discovery failure (Sections
4.3 and 6.10).

Results follow the three outcomes of Appendix C.1. Errors map to
400, 403, and 429, and WriteChallenge emits Accept-Signature
(Section 5.3). Nonces are recorded only after a signature verifies,
so a forger cannot burn a real agent's nonce, and a nonce store
failure yields unverified rather than a false claim of replay
protection (Appendix C.6).

Assisted-by: Claude Opus 5.5 via Claude Code
Signed-off-by: Xe Iaso <me@xeiaso.net>

* feat: serve and fetch key directories

NewDirectoryHandler serves the public halves of an agent's keys at
the well-known URI with kid set to each key's thumbprint. Every
response carries Content-Digest and one signature per key over
("@authority";req "content-digest") tagged
http-message-signatures-directory (httpsig-protocol-00 Appendix
B.1), with a long signature lifetime per Appendix C.8. It supports
HEAD, ETag revalidation, and permissive CORS (Appendix C.11).

Fetcher resolves Signature-Agent identifiers for the Verifier. Since
the URL comes from the request, fetches are hardened per Section
6.7: https only, no proxy, a dial-time check that rejects loopback,
private, link-local, CGNAT, NAT64, 6to4 and other special ranges
(so DNS rebinding cannot bypass it), a body cap after decoding, a
timeout, and an optional host allow-list. Redirects are not followed
and anything but 200 is a discovery failure (Section 5.5).

Caching follows Appendix C.4 and C.5: Cache-Control and Expires set
the TTL within bounds, stale entries are revalidated with
conditional requests, and failures back off exponentially with
jitter, honor Retry-After, and never exceed five minutes. A failed
fetch never evicts a cached directory, while a successful one always
replaces it (Section 6.10). Concurrent fetches are coalesced and
limited per origin (Appendix C.3).

Directory response signature checking is opt-in, since Appendix B
lets verifiers use directly resolved keys without it.

Assisted-by: Claude Opus 5.5 via Claude Code
Signed-off-by: Xe Iaso <me@xeiaso.net>

* test: exercise signer, directory, fetcher, and verifier end to end

An agent signs through Signer.Transport, the origin verifies with a
Verifier whose Fetcher discovers the key from a DirectoryHandler with
directory response signatures checked, all over TLS. Covers a
verified request, an unsigned one getting Accept-Signature, a key
missing from the directory, and a replay getting 429.

Assisted-by: Claude Opus 5.5 via Claude Code
Signed-off-by: Xe Iaso <me@xeiaso.net>

* ci: test only on stable Go and fix coverage upload

go.mod now requires Go 1.27, which oldstable cannot build. The
Codecov step checked for a 'latest' matrix entry that never existed,
so coverage was never uploaded.

Assisted-by: Claude Opus 5.5 via Claude Code
Signed-off-by: Xe Iaso <me@xeiaso.net>

* docs: document verifier, signer, and directory handler usage

Assisted-by: Claude Opus 5.5 via Claude Code
Signed-off-by: Xe Iaso <me@xeiaso.net>

* feat: add key generation, PEM, and directory helpers

GenerateKey creates Ed25519, ECDSA P-256/P-384, and RSA-PSS keys.
MarshalPrivateKeyPEM and ParsePrivateKeyPEM move them to and from
PEM (PKCS #8, plus SEC 1 and PKCS #1 on input). PublicJWK and
MarshalDirectory build the published JWKS with kid set to the
thumbprint and use set to sig.

NewDirectoryHandler now uses MarshalDirectory, so a directory
generated offline is byte for byte what the handler serves.

Assisted-by: Claude Opus 5.5 via Claude Code
Signed-off-by: Xe Iaso <me@xeiaso.net>

* feat(cmd/samesame): add a CLI for Web Bot Auth keys

A urfave/cli app with three commands:

- keygen writes a new private key with mode 0600, refusing to
  overwrite without --force, and prints its keyid
- directory prints the key directory JSON for one or more private or
  public keys, for publishing and rotation
- keyid prints the thumbprint of existing keys

Assisted-by: Claude Opus 5.5 via Claude Code
Signed-off-by: Xe Iaso <me@xeiaso.net>

* fix: validate directory keys up front and cache their verifiers

Directory parsing only rejected keys with a "d" member, so symmetric
oct keys, unsupported curves, and weak RSA keys were accepted as
valid. The oct and curve cases then surfaced only at verify time as
an unknown key, and a weak RSA key was actually used to verify.

NewKey now validates a JWK once: no symmetric or private keys, a
supported type and curve, and RSA moduli of at least 2048 bits
(also enforced when signing). It caches the verifier constructor, so
the verifier no longer exports the raw key on every request, and
static keys, the signer, and custom KeyResolvers share the same
path. Keys built by hand still verify through a fallback.

Assisted-by: Claude Opus 5.5 via Claude Code
Signed-off-by: Xe Iaso <me@xeiaso.net>

* fix: harden the directory fetcher against leaks and stalls

- Replace the per-origin semaphore map, which grew by one entry per
  host forever, with one global MaxConcurrentFetches limit. Fetches
  of an identifier are already coalesced, and a directory identifier
  is one per origin, so each origin still has at most one fetch in
  flight (Appendix C.3).
- Use singleflight DoChan so a caller whose context is cancelled
  returns at once, while the shared fetch continues and fills the
  cache for everyone else.
- Check directory response signature expiry against the Fetcher's
  clock with a configurable ClockSkew, instead of mixing the injected
  clock for created with httpsign's wall clock for expires.
- Raise the response header limit to 64 KiB. A signed directory has
  one Signature and Signature-Input per key, and 32 RSA keys exceed
  16 KiB.
- Reuse the verifier cached on each Key when checking bindings.

Assisted-by: Claude Opus 5.5 via Claude Code
Signed-off-by: Xe Iaso <me@xeiaso.net>

* fix(handler)!: sign directories only for configured hosts

The directory handler signed "@authority";req for whatever Host a
client sent, so anyone could fetch the directory with Host set to
their own domain and get valid Appendix B.1 proofs binding the bot's
keys to it. A verifier checking directory signatures would then
accept those keys as bound to the attacker's domain.

DirectoryHandlerOptions.Authorities is now required. Requests for
other hosts get 421 Misdirected Request and no signatures, and
signatures are made for the canonical lowercase host.

Every unauthenticated GET also cost one private key signature per
key. Signatures are now cached per authority for up to an hour, and
the cache is bounded by the Authorities list.

BREAKING CHANGE: NewDirectoryHandler returns an error unless
DirectoryHandlerOptions.Authorities lists the hosts to serve.

Assisted-by: Claude Opus 5.5 via Claude Code
Signed-off-by: Xe Iaso <me@xeiaso.net>

* fix(nonce)!: scope nonces per agent and evict instead of refusing

MemoryNonceStore had one global capacity and refused new nonces when
full, which the verifier reports as unverified. Anyone with their own
key directory could fill it with validly signed requests carrying
24 hour expiries and make every other agent unverifiable for a day.

Nonces are now scoped by the agent's identifier, or keyid for static
keys, so agents cannot collide. When the store is full it evicts the
soonest-expiring nonce of the scope holding the most entries, found
in constant time, so a flood degrades replay protection for the
heaviest scope, usually the flooder, instead of breaking
verification for everyone. Evicted counts these events, and the
expiry heap is compacted so a sustained flood cannot grow memory
past twice the capacity.

Result.NonceChecked reports whether a nonce store actually checked
the signature's nonce.

BREAKING CHANGE: NonceStore.CheckAndRecord takes a scope argument,
and MemoryNonceStore no longer returns ErrNonceStoreFull.

Assisted-by: Claude Opus 5.5 via Claude Code
Signed-off-by: Xe Iaso <me@xeiaso.net>

* fix(cmd/samesame): accept PKCS #1 RSA public keys

readPublicKey only recognized PKIX "PUBLIC KEY" blocks, so an "RSA
PUBLIC KEY" file fell through to the private key parser and failed
with a misleading error, even though the help text says public keys
are accepted.

Assisted-by: Claude Opus 5.5 via Claude Code
Signed-off-by: Xe Iaso <me@xeiaso.net>

* feat: precompute signed directories for static file servers

SignStaticDirectory returns the directory body plus the Content-Type,
Cache-Control, Content-Digest, Signature-Input, and Signature headers
for one authority. Directory response signatures cover only
"@authority";req and content-digest, so they are the same for every
request and can be configured as static headers in a file server.
It reuses the handler's signing path, so static and live directories
are identical.

Assisted-by: Claude Opus 5.5 via Claude Code
Signed-off-by: Xe Iaso <me@xeiaso.net>

* feat(cmd/samesame): print nginx and Caddy config for signed directories

`samesame directory --sign-for HOST --out FILE` writes the directory
byte for byte and prints an nginx location block and a Caddy handle
block that serve it with the right media type, Cache-Control,
Content-Digest, and one directory response signature per key.
--format picks nginx, caddy, or both; --lifetime and --max-age tune
the signatures and caching. Hosts are lowercased to match the
authority the signatures bind.

The blocks disable compression for the file, since Content-Digest
covers the exact bytes sent. Both were checked against real servers:
Caddy 2.11 with site-wide encode, and nginx in Docker with gzip on
for every type. The Fetcher verified all keys from each, and dropped
them when the same nginx was reached under a host it was not signed
for.

Assisted-by: Claude Opus 5.5 via Claude Code
Signed-off-by: Xe Iaso <me@xeiaso.net>

* chore: add var directory for local keys

The directory is ignored except for its .gitignore, so private keys
kept there for local testing never get committed.

Assisted-by: Claude Opus 5.5 via Claude Code
Signed-off-by: Xe Iaso <me@xeiaso.net>

* feat(cmd/samesame): serve a key directory from a folder of keys

`samesame serve --keys DIR --authority HOST` serves the signed key
directory for every .pem private key in DIR and watches the folder
with fsnotify, so adding or deleting a key file rotates keys without
a restart. It watches the folder rather than the files, so renames
into place and symlink swaps are seen, batches bursts of events, and
also reloads on a poll interval in case an event is missed.

If any .pem fails to parse, as when a file is caught mid-write, the
previous keys stay served, so a key is never dropped by accident.
Deleting every key serves 503. The handler is only rebuilt when the
set of keys changes, which keeps its signature cache.

Assisted-by: Claude Opus 5.5 via Claude Code
Signed-off-by: Xe Iaso <me@xeiaso.net>

* feat(cmd/samesame): report the version stamped by the Go toolchain

samesame --version reads runtime/debug.ReadBuildInfo: the module
version for go install ...@Version, or the pseudo-version Go derives
from git tags and the commit for builds in a checkout. No -ldflags
are needed.

Assisted-by: Claude Opus 5.5 via Claude Code
Signed-off-by: Xe Iaso <me@xeiaso.net>

* build: add a Docker image for samesame serve

A multi-stage Dockerfile cross-compiles on the build platform, so
linux/amd64 and linux/arm64 build without emulation, into a
distroless static image that runs as a non-root user. The binary's
version comes from git, so .git stays in the build context while
.dockerignore keeps var/ and every *.pem out of it.

docker-bake.hcl builds the multi-platform image, or a local one with
`docker buildx bake local`, and inherits its tags from a
docker-metadata-action target that CI replaces.

Assisted-by: Claude Opus 5.5 via Claude Code
Signed-off-by: Xe Iaso <me@xeiaso.net>

* ci: build and push the image to ghcr.io/techarohq/samesame

Pull requests build the image without pushing. Pushes to main and
develop publish branch and sha tags. Releases publish vX.Y.Z, vX.Y,
and latest.

semantic-release tags with GITHUB_TOKEN, which does not trigger other
workflows, so release images are built on workflow_run after the
Release workflow succeeds, from the newest tag. The build uses the
checkout rather than a git context so the Go toolchain can stamp the
version from .git.

Assisted-by: Claude Opus 5.5 via Claude Code
Signed-off-by: Xe Iaso <me@xeiaso.net>

* build: run goimports from the module's tool directive in the hook

The pre-commit hook called $HOME/go/bin/goimports, which only exists
if it was installed by hand, so commits failed on a clean machine.
go.mod already declares goimports as a tool, and the fmt script uses
go tool goimports.

Assisted-by: Claude Opus 5.5 via Claude Code
Signed-off-by: Xe Iaso <me@xeiaso.net>

* fix: handle errors flagged by golangci-lint

Printed output from the CLI now returns its write errors: the server
config writer remembers the first error across its many writes, and
keygen, keyid, and the directory separators check theirs. Close calls
that are only cleanup after another error, or after a response body
is done, discard their error explicitly, as do test handlers.

Assisted-by: Claude Opus 5.5 via Claude Code
Signed-off-by: Xe Iaso <me@xeiaso.net>

* ci: lint with golangci-lint v2

golangci-lint-action v6 installs golangci-lint v1, whose latest
release is built with Go 1.24 and refuses to lint a Go 1.27 module.
Use action v9 with golangci-lint pinned to v2.14.0, the version that
passes locally.

Assisted-by: Claude Opus 5.5 via Claude Code
Signed-off-by: Xe Iaso <me@xeiaso.net>

* test: check nonce eviction scaling by ratio, not wall clock

TestMemoryNonceStoreManyScopes required 200k inserts to finish in
five seconds. Under -race in CI they took 5.6s, about 23 times the
normal 0.24s, so the test was flaky while still loose enough to miss
regressions.

It now compares inserts spread over one scope each against inserts
over eight scopes. Constant-time eviction keeps the ratio near 1.2x;
a linear scan for the largest scope makes it about 70x. The race
detector slows both sides equally, so the 10x limit holds under it.

Assisted-by: Claude Opus 5.5 via Claude Code
Signed-off-by: Xe Iaso <me@xeiaso.net>

---------

Signed-off-by: Xe Iaso <me@xeiaso.net>

---------

Signed-off-by: Xe Iaso <me@xeiaso.net>
github-actions Bot pushed a commit that referenced this pull request Sep 28, 2026
# [1.0.0](v0.0.0...v1.0.0) (2026-09-28)

### Features

* basic functionality ([#5](#5)) ([57c8f0e](57c8f0e)), closes [#4](#4) [#8](https://github.com/TecharoHQ/samesame/issues/8) [#1](#1) [#1](#1)

### BREAKING CHANGES

* NewDirectoryHandler returns an error unless
DirectoryHandlerOptions.Authorities lists the hosts to serve.

Assisted-by: Claude Opus 5.5 via Claude Code
Signed-off-by: Xe Iaso <me@xeiaso.net>

* fix(nonce)!: scope nonces per agent and evict instead of refusing

MemoryNonceStore had one global capacity and refused new nonces when
full, which the verifier reports as unverified. Anyone with their own
key directory could fill it with validly signed requests carrying
24 hour expiries and make every other agent unverifiable for a day.

Nonces are now scoped by the agent's identifier, or keyid for static
keys, so agents cannot collide. When the store is full it evicts the
soonest-expiring nonce of the scope holding the most entries, found
in constant time, so a flood degrades replay protection for the
heaviest scope, usually the flooder, instead of breaking
verification for everyone. Evicted counts these events, and the
expiry heap is compacted so a sustained flood cannot grow memory
past twice the capacity.

Result.NonceChecked reports whether a nonce store actually checked
the signature's nonce.
* NonceStore.CheckAndRecord takes a scope argument,
and MemoryNonceStore no longer returns ErrNonceStoreFull.

Assisted-by: Claude Opus 5.5 via Claude Code
Signed-off-by: Xe Iaso <me@xeiaso.net>
Xe pushed a commit that referenced this pull request Sep 28, 2026
# [1.0.0](v0.0.0...v1.0.0) (2026-09-28)

### Features

* basic functionality ([#5](#5)) ([57c8f0e](57c8f0e)), closes [#4](#4) [#8](https://github.com/TecharoHQ/samesame/issues/8) [#1](#1) [#1](#1)

### BREAKING CHANGES

* NewDirectoryHandler returns an error unless
DirectoryHandlerOptions.Authorities lists the hosts to serve.

Assisted-by: Claude Opus 5.5 via Claude Code
Signed-off-by: Xe Iaso <me@xeiaso.net>

* fix(nonce)!: scope nonces per agent and evict instead of refusing

MemoryNonceStore had one global capacity and refused new nonces when
full, which the verifier reports as unverified. Anyone with their own
key directory could fill it with validly signed requests carrying
24 hour expiries and make every other agent unverifiable for a day.

Nonces are now scoped by the agent's identifier, or keyid for static
keys, so agents cannot collide. When the store is full it evicts the
soonest-expiring nonce of the scope holding the most entries, found
in constant time, so a flood degrades replay protection for the
heaviest scope, usually the flooder, instead of breaking
verification for everyone. Evicted counts these events, and the
expiry heap is compacted so a sustained flood cannot grow memory
past twice the capacity.

Result.NonceChecked reports whether a nonce store actually checked
the signature's nonce.
* NonceStore.CheckAndRecord takes a scope argument,
and MemoryNonceStore no longer returns ErrNonceStoreFull.

Assisted-by: Claude Opus 5.5 via Claude Code
Signed-off-by: Xe Iaso <me@xeiaso.net>
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