Repository navigation
feat: implement Web Bot Auth (httpsig-protocol-00) - #4
Merged
Merged
Conversation
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>
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
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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 eachweb-bot-authsignature independently and returns one of three outcomes: verified, invalid, or unverified (Appendix C.1).created,expires,keyid, the tag, and coverage of@authorityor@target-uri.Signature-Agentmember through the covered component's;key=, never by label. Accepts the legacy string form.Accept-Signaturechallenges; 400, 403, and 429 helpers.Fetcher: resolvesSignature-Agentorigins to key directories. The URL comes from the client, so fetching is hardened (Section 6.7):Caching uses HTTP semantics. A failed fetch never evicts a cached directory. Checking directory response signatures is optional.
SignerandTransport: 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 configuredAuthorities. Other hosts get 421.SignStaticDirectoryand key helpers:GenerateKey, PEM helpers,PublicJWK,MarshalDirectory.cmd/samesame(urfave/cli)keygen,keyid, anddirectory.directory --sign-for HOSTprints nginx and Caddy config for a signed static directory.servewatches a folder of.pemkeys and serves the directory, reloading when the files change.--versionreads the version the Go toolchain stamps from git.Packaging and CI
Distroless multi-arch
Dockerfileanddocker-bake.hcl..dockerignorekeepsvar/and all*.pemfiles out of the build context..github/workflows/docker.ymlpublishes toghcr.io/techarohq/samesame:mainanddeveloppush branch andsha-tags;vX.Y.Z,vX.Y, andlatest.Release images are built on
workflow_runafter Release, because semantic-release tags withGITHUB_TOKEN, which does not trigger other workflows.ci.ymldropsoldstable, because the module needs Go 1.27, and fixes the Codecov condition, which never matched.Spec notes
kidthat is not the key thumbprint. The parser correctly rejects it.Cloudflare interop (tested live)
"signature-agent";key=dictionary component, which -00 requires. It accepts the legacy string form.("@authority";req)withoutcontent-digest, so its key is dropped only whenVerifyDirectorySignaturesis on.Test plan
go test ./...andgo vet ./...pass, 3 runs in a row with no flakes. Coverage: 92.7% for the library, 81% for the CLI.Signature-Agentparsing and verification, about 20M runs in total./code-review highfindings fixed: directory host binding, a nonce-store DoS, a fetcher memory leak and stalls, and weak key acceptance.samesame servesmoke-tested as a binary and as a container, including key rotation, 503 when no keys remain, and SIGTERM.docker buildx bakebuilds amd64 and arm64. actionlint passes.Assisted-by: Claude Opus 5.5 via Claude Code