Verifying the atc / atcd binary you’re running
atc and atcd ship as closed-source binaries. This doc explains the
verification mechanism that already protects every install and self-update,
and how you — a customer — can independently check that the binary on your
machine or device is genuine and unmodified.
The digest check below (sha256 against the published release assets)
needs only curl, sha256sum/shasum, and jq — no access to this repo.
The signature check is stronger and needs nothing extra either:
atc sign verify ships inside the atc binary you already installed. It has
one honest limit: the check runs inside the very binary whose provenance you
may be questioning. For a verifier fully independent of your installed atc,
check the same signature with stock OpenSSL (recipe in that section). Either
way you need no access to this repo. The same ed25519 check also already runs
for you, automatically, on every self-update (see below).
If you are working in this codebase, see
docs/development/README.md instead.
Mental model
Every release is described by a signed release index (index.json):
- It lists, for each package (
agency/atc,agency/atcd) and platform target (e.g.linux-x86_64-musl,darwin-arm64), the artifact’s sha256 digest, byte size, and download URL. - The index is signed with ed25519 by Agency’s release-signing key. The
detached signature ships alongside it as
index.json.sig. - The signing key is pinned inside the
atcbinary itself (crates/atc-update/src/keys.rs,AGENCY_PUBKEYS_HEX) — not fetched at runtime from anywhere an attacker on the network path could substitute.
So there are two independent checks, and both must pass before a binary is trusted:
- Digest check — the downloaded binary’s sha256 must equal the digest the index lists for that platform.
- Signature check — the index itself must carry a valid ed25519 signature
from a key
atcalready has pinned in its binary.
A binary that matches the digest in a tampered (unsigned, or wrongly-signed) index does not pass. A signed index pointing at a digest that doesn’t match the downloaded bytes does not pass either. Both layers are required.
What verifies this automatically
You don’t have to do anything by hand for this to protect you — it already runs on every install and every self-update:
- First install (
curl -fsSL https://atc.dev/install.sh | sh, seetools/scripts/install.sh): fetchesindex.jsonover TLS, parses out the digest and URL for your platform, downloads the artifact, and refuses to install it if the computed sha256 does not match the digest in the index. This first hop relies on TLS + the digest for integrity — the install script itself does not check the ed25519 signature (it has no signing-key material and is deliberately dependency-free: no Python, no jq, justcurl,awk, andsha256sum/shasum). - Device install of
atcd(curl -fsSL https://atc.dev/install-atcd.sh | sudo sh, seetools/scripts/install-atcd.sh): checks the ed25519 signature onindex.jsonwithopensslagainst the pinned key, before it reads the index. Then it checks that the index names the requested track, and the version ifATC_VERSIONis set. Then it checks the sha256 ofatcdagainst the signed digest. If any check fails, it stops and does not runatcd. It needs OpenSSL 3, for ed25519 with-rawin. - Every subsequent update (
atc upgrade, implemented incrates/atc-cmd-bootstrap/src/self_update.rsandcrates/atc-update/src/selfupdate.rs): the binary itself fetchesindex.jsonandindex.json.sig, verifies the ed25519 signature against its pinned key set (crates/atc-update/src/sign.rs::verify_bytes,crates/atc-update/src/fetch.rs::fetch_verified_index), and only then downloads the artifact and checks its sha256 (fetch_artifact). If the source has no trusted key pinned, or the signature doesn’t verify, or the digest doesn’t match, the update is refused outright — nothing is ever applied on a verification failure. This is cryptographic, end-to-end authenticity, enforced by the binary itself, not by the transport.
In short: after your first install, you never have to trust a network path
again — atc will not update itself to anything it can’t verify against its
own pinned key.
Independently verifying an already-installed binary
If you want to check a binary yourself, rather than trusting that the
updater did its job, here is exactly what to do. The digest check (step 2)
needs sha256sum (or shasum), jq, and the published release assets that
step 1 fetches with curl alone. The SHA256SUMS route at the end of step 2
needs no jq. The signature check (step 3) is one command: atc sign verify, built into the atc you already installed. The paranoid-grade
alternative, for a verifier independent of that installed binary, is checking
the same signature with stock OpenSSL.
The GitHub CLI (gh) is optional. You need it only to take the assets from a
GitHub release instead of the origin, or to look up a release tag.
1. Get the published release assets
Every nightly build publishes, alongside the platform binaries:
SHA256SUMS— plain sha256sums of each staged binaryindex.json— the signed release index described aboveindex.json.sig— its detached ed25519 signature
All three are attached to the corresponding GitHub release (e.g.
nightly-2026-07-01). Fetch them either way — from the release origin, which
needs nothing but curl:
track=nightly # the track your atc came from: atc --version prints it
curl -fsSL "https://atc.dev/agency/$track/index.json" -o index.json
curl -fsSL "https://atc.dev/agency/$track/index.json.sig" -o index.json.sig
The dev track is internal and token-gated, so those two curl commands
return 403 for it. The edge serves /agency/dev/* only to requests that carry
X-Atc-Channel-Token. Send the token to verify a dev build:
track=dev
curl -fsSL -H "X-Atc-Channel-Token: $ATC_CHANNEL_TOKEN" \
"https://atc.dev/agency/$track/index.json" -o index.json
curl -fsSL -H "X-Atc-Channel-Token: $ATC_CHANNEL_TOKEN" \
"https://atc.dev/agency/$track/index.json.sig" -o index.json.sig
Get the token from 1Password, and export it as ATC_CHANNEL_TOKEN. The
Opting in section of onboarding.md gives the item
and the whitespace rule. The stable and nightly tracks are public, and they
need no token. A dev build also has no GitHub release, so the gh release download alternative below cannot replace this fetch.
For a stable or nightly build you can also fetch from the GitHub
release, which is where SHA256SUMS lives:
gh release download nightly-2026-07-01 -R Agency-Tool-Company/agency-code \
-p 'index.json' -p 'index.json.sig' -p 'SHA256SUMS'
Finding the release tag for a binary you already have. atc --version
prints the version and the track. Each track spells its tag differently, so do
not guess the tag from the version alone:
atc --version # e.g. "atc v0.1.6 (355c6f1013 2026-10-15T16:23:54Z, 0.1.6, track stable) [wire atc/6]"
# or "atc v0.1.7-nightly.20261016 (2b5eb47bb7 2026-10-16T12:50:16Z,
# 0.1.7-rc.202610161245.2b5eb47bb7fa, track nightly) [wire atc/6]"
The leading token is a short human form. The exact version sits in the
parenthetical, between the build date and the track, and that is the one the
tag lookup below reads. For a script, read atc -V instead. It prints atc
and the exact version, for example atc 0.1.6, so the version is the second
field.
A build appends the wire generation it speaks, as a trailing [wire atc/N]. A
release that predates the banner prints no such segment, and one that predates
the short form leads with the exact version instead (see
onboarding.md). The banner is not
part of the version or the track, so it never changes the tag lookup below.
-
stable: the tag is
vplus the version, so0.1.3becomesv0.1.3. -
nightly: the tag is not derived from the version. A nightly version reads
0.1.4-rc.<YYYYMMDDHHMM>.<sha>, where the trailing<sha>is the commit it was built from. Match on that sha, not on the date. The tag is usuallynightly-<YYYY-MM-DD>for the same day. A manually dispatched nightly can carry an operator-chosenrelease_datein its tag, while the version keeps the real build timestamp. The two dates then disagree. The sha always agrees:sha=2b5eb47bb7fa # the trailing dot-field of the version in the parens gh api "repos/Agency-Tool-Company/agency-code/releases?per_page=100" \ --paginate --jq ".[] | select(.name|contains(\"$sha\")) | .tag_name" # nightly-2026-10-16--paginatewalks every release, so this also finds a nightly far older than any fixedgh release list -L <n>window would show. If you already know the date is trustworthy,gh release view nightly-<YYYY-MM-DD>is the quick path.
Both give you the same signed index. Step 2 below assumes index.json is in the
current directory.
The atc.dev release origin the installer/self-update
use serves index.json and index.json.sig, but not SHA256SUMS. The
origin publishes binaries as content-addressed blobs keyed by the digests
index.json already lists, so https://atc.dev/agency/<track>/SHA256SUMS
returns 404 by design. So verify against index.json, which you always have;
SHA256SUMS is a convenience if you happened to pull it from GitHub.
2. Check the digest
Check against index.json — it’s the one asset both the release origin and the
GitHub release always carry, and it’s the file the signature in step 3 covers:
The index stores the digest as sha256:<hex>, while sha256sum/shasum print
bare hex, so strip the prefix before comparing rather than eyeballing the two:
bin=atc-linux-x86_64-musl # the artifact you downloaded
# ...or the binary already on this machine:
# bin=$HOME/.local/bin/atc
target=linux-x86_64-musl # its key in index.json
got=$(sha256sum "$bin" | cut -d' ' -f1) # Linux
# macOS: got=$(shasum -a 256 "$bin" | cut -d' ' -f1)
want=$(jq -r --arg t "$target" \
'.artifacts."agency/atc"[$t].digest' index.json | sed 's/^sha256://')
[ "$got" = "$want" ] && echo "digest OK" || echo "MISMATCH: $got != $want"
If you pulled SHA256SUMS from the GitHub release, it checks every binary in
one command (but see above — the origin doesn’t serve it):
sha256sum -c SHA256SUMS # Linux
shasum -a 256 -c SHA256SUMS # macOS
If your locally-installed atc/atcd was placed by the installer or by
atc upgrade, this is the same check those
tools already performed for you — a mismatch here would mean something
altered the binary after it was verified and installed (e.g. local tampering
on disk), since both paths refuse to write a binary whose digest doesn’t
match in the first place.
3. Check the index signature
The signature check is what stops a compromised or spoofed download origin
from ever making it to your machine — even if an attacker could rewrite
index.json and its listed digests to point at a malicious binary, they
cannot forge a valid signature over it without the private key (held in
1Password, surfaced to CI only as ATC_RELEASE_SIGNING_KEY; see
crates/atc-update/src/keys.rs).
The key below can rotate. Don’t treat it as permanent.
atcsupports signing-key rotation: a future release can add a new key alongside this one for a transition window, then retire this one. The canonical, always-current source of truth for which key(s) are trusted isAGENCY_PUBKEYS_HEXincrates/atc-update/src/keys.rsat the commit your installedatcwas built from — not this doc. If a signature check against the key below ever fails on an otherwise-current release, check that file (or ask Agency) before assuming tampering; it may mean the key has rotated since this page was last updated.
The production public key, pinned in every atc build as of this
writing, is:
8f0661861de15f450781f8cdb08a33e094aff7c67539371d9ecc4dd12362202d
(from AGENCY_PUBKEYS_HEX, which is a set — it can carry more than one key
during a rotation window; this is the sole key in production today.) The
first 8 bytes of that key, hex-encoded, are the key_id prefix on every
index.json.sig line — you can confirm at a glance that a given signature
claims to be from this key before doing the cryptographic check:
$ cat index.json.sig
8f0661861de15f45 28436bd3a8afc3686edf45e9af39862b41bbe514692f55f41260677e8aec3b3624907251769d651102235db03cb9811905e904d4853eb2289587378829bb4a07
# ^^^^^^^^^^^^^^^^ key_id — matches the first 8 bytes of the pinned pubkey above
To actually verify the ed25519 signature (not just eyeball the key id), run
atc sign verify — the exact verification code path atc’s own self-update
uses (atc_update::sign::verify_bytes), exposed as a subcommand for exactly
this purpose:
atc sign verify index.json index.json.sig \
8f0661861de15f450781f8cdb08a33e094aff7c67539371d9ecc4dd12362202d
# -> OK (signature verifies against the pinned key; exit 0)
# -> VERIFY FAILED: ... (reject the index — do not trust any binary it points at; exit 1)
Pass more than one pubkey during a rotation window; the check passes if the
signature matches any of them. atc sign also carries keygen and create
for producing your own detached signatures.
One honest caveat: this check runs inside the very atc binary whose
provenance you may be questioning, and a hypothetically malicious atc could
lie about it. If that’s your threat model, verify with a tool that isn’t ours.
The format is deliberately standard, so stock OpenSSL 3.x does it. The one
wrinkle is that index.json.sig is a line of two hex fields,
<key_id> <signature> (the key_id selects a key during rotation), so pass
field 2 rather than the whole file:
# Field 2 of the .sig line is the raw 64-byte ed25519 signature.
awk '{print $2}' index.json.sig | xxd -r -p > sig.raw
# Wrap the pinned hex public key in the standard ed25519 DER prefix.
printf '302a300506032b6570032100%s' <pinned-pubkey-hex> | xxd -r -p > atc-pub.der
openssl pkeyutl -verify -pubin -inkey atc-pub.der -keyform DER \
-rawin -in index.json -sigfile sig.raw
# -> "Signature Verified Successfully"
Putting it together
A binary is genuine and untampered iff both hold:
sha256(binary) == index.json's digest for that platform/package, andindex.json’s signature verifies against a key in the trusted set (8f0661861de15f450781f8cdb08a33e094aff7c67539371d9ecc4dd12362202das of this writing — see the rotation caveat above).
If either check fails, do not run the binary — and note that atc’s own
installer and self-update already refuse to complete in that situation, so a
failure here on a binary that came from those paths would itself be a strong
signal of something wrong outside atc’s control (a compromised local disk,
a mirrored/proxied download that served different bytes, etc.).
Where the trust root comes from
- The ed25519 keypair was generated via a secure host + 1Password (
op) ceremony (seecrates/atc-update/src/keys.rsfor the pointer to the private-key location:op://Agency/atc-release-signing/private-key). - The private half never leaves 1Password/CI: it’s surfaced to the nightly
release workflow only as the
ATC_RELEASE_SIGNING_KEYGitHub Actions secret, used solely to signindex.jsonat release time (tools/scripts/stage-nightly-release-binaries.sh). - The public half is compiled into the
atcbinary as a Rust constant (AGENCY_PUBKEYS_HEX) — there is no runtime fetch of “the current trust key” that a network attacker could redirect. Trusting a givenatcbinary build (via the digest/signature chain above, or by building from this source yourself) is what transitively trusts the key it carries. AGENCY_PUBKEYS_HEXis a set, not a single key, specifically so the signing key can be rotated: a future build adds the new key alongside the old one for a rotation window, then drops the retired one once every supportedatchas upgraded past it.
What this does not cover
- The signature check has a CLI verb (
atc sign verify); the digest check does not. There is noatc doctorthat runs both against your installed binary and reports one verdict. Do the digest check by hand, as described above. - There is no standalone public page publishing the current trust key. Both the
digest and signature checks are fully self-service with standard tools
(
sha256sum, OpenSSL), but the trusted key itself reaches you either baked into anatcbuild or copied from this page. Treat the pinned key printed above as a snapshot, not a permanent value, and see the rotation caveat in the signature-check section. - This verifies what Agency’s release pipeline published, not that the source code matches some external audit — it’s a supply-chain integrity check (this exact binary came from this exact signed release), not a reproducible-build proof.