The Web Bot Auth implementation for Anubis.
samesame lets HTTP clients sign requests and lets servers verify who sent them, following draft-ietf-webbotauth-httpsig-protocol-00 on top of HTTP Message Signatures (RFC 9421). It has three parts:
Verifierchecks signatures on incoming requests. Anubis uses this.Signersigns outgoing requests for a bot.NewDirectoryHandlerserves a bot's public keys at/.well-known/http-message-signatures-directory.
It requires Go 1.27 or later.
v, err := samesame.NewVerifier(samesame.VerifierOptions{
Resolver: samesame.NewFetcher(samesame.FetcherOptions{}),
NonceStore: samesame.NewMemoryNonceStore(0),
})
if err != nil {
return err
}
res, err := v.Verify(r)
switch samesame.OutcomeOf(err) {
case samesame.OutcomeVerified:
// res.Identifier is the agent's key directory URL, for example
// https://bot.example/.well-known/http-message-signatures-directory.
// It is nil when the key came from VerifierOptions.StaticKeys; then
// res.KeyID is the only identity.
case samesame.OutcomeInvalid:
// A signature is present and wrong: bad signature, expired, replayed,
// or not following the web-bot-auth profile.
http.Error(w, "invalid signature", samesame.StatusCode(err))
case samesame.OutcomeUnverified:
// Unsigned, unknown key, or key discovery failed. This is not proof of
// anything. Treat it as a signal, not as a verdict.
}The verifier:
- requires
created,expires,keyid, andtag="web-bot-auth"; - requires
@authorityor@target-urito be covered; - limits
expires - createdto 24 hours by default; - finds the
Signature-Agentmember through the covered component's;key=parameter, and accepts the legacy string form of the header; - refuses signatures sent over plain HTTP.
Behind a proxy that terminates TLS, set VerifierOptions.Scheme to read
the original scheme from a header that you trust.
Fetcher downloads key directories named by Signature-Agent. That URL
comes from the client, so the fetcher only uses https, does not follow
redirects, and refuses to connect to loopback, private, link-local, and
other non-public addresses. It also limits size, time, and concurrency. It
caches directories using the response's caching headers. If a refresh
fails, the fetcher keeps the last good directory.
s, err := samesame.NewSigner(privateKey, samesame.SignerOptions{
AgentOrigin: "https://bot.example",
})
if err != nil {
return err
}
client := &http.Client{Transport: s.Transport(nil)}Supported keys are Ed25519, ECDSA P-256 and P-384, and RSA (RSA-PSS SHA-512). By default, each signature is valid for one hour and has a random nonce.
h, err := samesame.NewDirectoryHandler([]crypto.Signer{privateKey}, samesame.DirectoryHandlerOptions{
// The hosts this directory is served for. Other hosts get 421.
Authorities: []string{"bot.example"},
})
if err != nil {
return err
}
mux.Handle(samesame.WellKnownPath, h)The handler publishes only the public keys. Each response has one
signature per key, which proves that the operator of that origin holds
the key. The signatures are bound to the requested host, so the handler
signs only for the hosts in Authorities. Otherwise anyone could get
proofs that bind your keys to their own domain. Signatures are cached per
host for up to an hour.
cmd/samesame generates keys and the directory to publish for them.
go install github.com/TecharoHQ/samesame/cmd/samesame@latest
# Make a key. The key's keyid is printed; the file has mode 0600.
samesame keygen --out bot.key
# Print the directory JSON to serve at
# /.well-known/http-message-signatures-directory.
samesame directory bot.key
# To rotate, publish the new key next to the old one first.
samesame keygen --alg ecdsa-p256-sha256 --out next.key
samesame directory bot.key next.key
# Print the keyid of existing private or public PEM keys.
samesame keyid bot.key next.keysamesame directory prints the same JSON that NewDirectoryHandler serves.
To serve the directory from nginx or Caddy instead of NewDirectoryHandler,
use --sign-for:
samesame directory bot.key next.key \
--sign-for bot.example \
--out /srv/www/http-message-signatures-directoryThis writes the directory to --out exactly as it must be served. It then
prints an nginx location block and a Caddy handle block that serve that
file with:
- the media type
application/http-message-signatures-directory+json; Cache-Control(set with--max-age);Content-Digest;- one directory response signature per key.
Use --format nginx or --format caddy to print only one of them.
These signatures prove that you hold the keys. Some verifiers require them, for example Cloudflare. They cover only the host and the body, so they can be static headers. Keep these points in mind:
- The signatures expire after
--lifetime(default 30 days). The printed config shows the exact time. Run the command again before then, and every time the keys change. - Give every host that verifiers use to reach the directory as its own
--sign-for. A signature for one host does not work for another host. - Do not compress this file.
Content-Digestcovers the exact bytes that the server sends. The nginx block turns gzip off. If your Caddy site usesencode, limit it to other paths, as the printed comment shows. - In nginx, an
add_headerin thelocationblock replaces theadd_headerdirectives that theserverblock would give it.
samesame serve serves the directory for every .pem private key in a
folder. It watches the folder, so you can add or delete a key file to
rotate keys without a restart:
samesame serve --keys ./var --authority bot.example --bind :8080- Responses carry directory signatures for each
--authority. Other hosts get 421. Repeat--authorityfor each host verifiers use. - If a
.pemfile does not parse, for example while it is being written, the previous keys stay served until the next good reload. - With no keys in the folder, it answers 503.
- It also reloads every
--poll(default one minute), in case a change event is missed. --keys,--authority, and--bindcan also be set withSAMESAME_KEYS,SAMESAME_AUTHORITY, andSAMESAME_BIND.
In Go, the same operations are GenerateKey, MarshalPrivateKeyPEM,
ParsePrivateKeyPEM, PublicJWK, MarshalDirectory, and
SignStaticDirectory.
The image runs samesame serve. It is published to
ghcr.io/techarohq/samesame.
docker run -d -p 8080:8080 \
--user "$(id -u):$(id -g)" \
-v "$PWD/var:/keys:ro" \
-e SAMESAME_AUTHORITY=bot.example \
ghcr.io/techarohq/samesame:latest- Mount your folder of
.pemprivate keys at/keys. The container keeps watching it. - For several hosts, give
SAMESAME_AUTHORITYas a comma-separated list, for examplebot.example,www.bot.example. - The image runs as the non-root user 65532. Private keys usually have mode
0600, so the container cannot read them unless it runs as their owner. Use--useras in the example. In Kubernetes, usefsGroupwith a SecretdefaultModeof0440. If the container cannot read the keys, it logspermission deniedand answers 503. samesame --versionprints the version that the Go toolchain stamped from git.
To build the image:
docker buildx bake local # samesame:local, for this machine
VERSION=v1.2.3 docker buildx bake --push # linux/amd64 and linux/arm64The Docker workflow runs as follows:
- Each pull request builds the image without pushing it.
- Each push to
mainordeveloppushes a branch tag and asha-tag. - Each release pushes
vX.Y.Z,vX.Y, andlatest. .dockerignorekeepsvar/and all*.pemfiles out of the build context.
jwks_uriandcimdmembers ofSignature-Agent. The verifier ignores them.- Delegation and certificate chains.
- The Signature Agent Card in draft-meunier-webbotauth-registry.
npm ci # commit hooks
go test ./...The implementation plan is in docs/plans/base-implementation.md.