The offline verifier for HodeiShield posture attestations, published by Hodeitek, so that you do not have to take our word for one. No credentials, account or cooperation needed.
Step-by-step guides: Spanish, English.
What an organisation shares with you is a URL, not a file:
https://app.hodeishield.com/api/public/attest/<slug>, or just the <slug>,
which is the last part of that URL. Fetch the document from it right before you
verify: a document is valid for at most one hour, so a copy saved earlier, or
one forwarded to you as a file, will fail the freshness check however genuine
it is.
The attestation and the signing key are served by
app.hodeishield.com. -f makes curl fail on an
HTTP error instead of saving the error page as att.json.
curl -fsS https://app.hodeishield.com/api/public/attest/talmaren-payments -o att.json
curl -fsS https://app.hodeishield.com/api/public/attest/keys -o jwks.json
bash scripts/attest/verify-attestation.sh \
--attestation att.json --jwks jwks.json \
--expect-slug talmaren-payments --expect-issuer https://app.hodeishield.comtalmaren-payments is a demonstration organisation with fictitious data, kept
so that you can try the tool. It is not a promise: a demo subject can be
retired, and then the endpoint answers 404. Substitute the slug of whichever
organisation's attestation you were given. Always pass --expect-issuer; the
document names its own issuer, and only you can say which one you trust.
web/ is a page that does the same check in the browser, for people who
do not use a terminal: add the attestation and the issuer's key file, press a
button, read the result. The files never leave the browser, and it works offline.
It is served at https://verify.hodeishield.com and tested (all 136 cases of
tests/vectors/v1 in headless Chrome and Firefox; Safari is not tested), and the
script above remains the reference verifier. It is released as verify-page-<tag>.tar.gz;
what it does, what it does not do and how to check the files it serves are in
docs/security/browser-verifier.md.
A posture attestation is a short document, signed by HodeiShield, stating how
far an organisation has got with frameworks such as ISO 27001 or NIS2 at a given
moment. A supplier, their Trust Center on HodeiShield or their answer to a
security questionnaire gives you the link to it,
https://app.hodeishield.com/api/public/attest/<slug>, or the slug alone. The
slug is the last part of that URL: in
https://app.hodeishield.com/api/public/attest/talmaren-payments it is
talmaren-payments, and it is the value you pass to --expect-slug. A Trust
Center badge carries the same link in its Link response header (see
§4.9 of the verification document).
Each document is valid for at most one hour after it is generated, so what you
verify is the copy you fetch from that URL, not a file someone sent you: a file
forwarded by email or attached to a questionnaire will usually be older than an
hour by the time you check it, and the verifier rejects it as too old or
expired. If you were sent only a file, ask for the link. This tool tells you
whether the document you fetched is genuine, unaltered, current and about the
organisation you think. It is a verifier you can read and run yourself, and the documentation
here explains exactly what a VERIFIED result does and does not mean; §6 of it
says what the tool cannot tell you.
HodeiShield issues these attestations. An organisation that wants to publish its own starts at hodeishield.com.
bash ≥ 4 |
This is bash, not POSIX sh. |
openssl ≥ 3.5 |
ML-DSA (FIPS 204) support landed in 3.5. Check with openssl list -signature-algorithms | grep -i ml-dsa-65. If that prints nothing, upgrade — a failure there is a limit of your tooling, not evidence against the document. LibreSSL (the openssl on macOS, or Homebrew's libressl) has no ML-DSA whatever its version number; the verifier checks this itself, says so and exits 2. If you cannot upgrade, use the container route. |
python3 ≥ 3.7 |
Required in practice. The endpoint serves a detached JWS, so the signed bytes must be re-derived from the JSON you can read. Standard library only. |
curl |
Only to fetch the two files above. Verification itself is offline. |
cosign ≥ 3.1.3 |
Only for --anchor-file (Anchor the key). Optional: nothing else needs it. |
xxd is used when present and is not required.
This is the supported route on macOS, Windows and Ubuntu 24.04 LTS, whose
OpenSSL and bash are too old or absent for the verifier. The only thing your
machine needs is Docker or Podman, plus curl to fetch the two files (curl
ships with macOS, Windows 10 and later, and WSL).
From the repository root, fetch the two files, then run one container command:
curl -fsS https://app.hodeishield.com/api/public/attest/talmaren-payments -o att.json
curl -fsS https://app.hodeishield.com/api/public/attest/keys -o jwks.jsondocker run --rm -v "$PWD":/work:ro -w /work \
debian:trixie-slim@sha256:a29215f6a35e51e22adffa17f89e9d2ef06214e64a2bad10d765c46aea49f11f \
bash -c 'apt-get update -qq >/dev/null && apt-get install -y -qq --no-install-recommends openssl python3 >/dev/null || exit 2; exec bash scripts/attest/verify-attestation.sh "$@"' _ \
--attestation att.json --jwks jwks.json \
--expect-slug talmaren-payments --expect-issuer https://app.hodeishield.comThe container mounts your files read-only, installs OpenSSL and python3
inside itself, and runs the same script. If that install fails (for example
because the container has no network), the command exits 2, "could not check",
so the exit codes are exactly the native ones: the table in
Exit codes.
To check revocation as well (see Revocation), fetch the status
list and its key set too, and run the same container with --status-list. Its
exit codes are the --status-list table, including 3 for "unknown":
curl -fsS https://app.hodeishield.com/api/public/attest/status -o status.json
curl -fsS https://app.hodeishield.com/api/public/attest/status-keys -o status-jwks.jsondocker run --rm -v "$PWD":/work:ro -w /work \
debian:trixie-slim@sha256:a29215f6a35e51e22adffa17f89e9d2ef06214e64a2bad10d765c46aea49f11f \
bash -c 'apt-get update -qq >/dev/null && apt-get install -y -qq --no-install-recommends openssl python3 >/dev/null || exit 2; exec bash scripts/attest/verify-attestation.sh "$@"' _ \
--status-list --status status.json --status-keys status-jwks.json \
--attestation att.json --jwks jwks.json \
--expect-slug talmaren-payments --expect-issuer https://app.hodeishield.com- Podman: the same commands with
podmaninstead ofdocker. On SELinux hosts (Fedora, RHEL) mount with-v "$PWD":/work:ro,Z. - Windows: install Docker Desktop and run the commands above from a WSL terminal, in the cloned repository. PowerShell quotes differently, so no PowerShell variant is offered.
The image is the upstream Debian 13 image, pinned by digest: the same one CI
tests on, so a rebuilt tag cannot change what you run. The digest pins the
base image; openssl and python3 come from Debian 13's archive when the
command runs, so they carry Debian's current security updates.
Releases after v1.4.0 also publish a ready-made image,
ghcr.io/hodeitek/hodeishield-attest-verifier, tagged with the version
(:vX.Y.Z). There is no latest tag, and v1.4.0 and earlier
releases have no image. It has openssl and python3 built in, runs as a non-root user,
and its entrypoint is the verifier, so it needs no network to run. Take its
digest from the release page, and pin that digest, not the tag. Check the
signature first, with cosign:
cosign verify ghcr.io/hodeitek/hodeishield-attest-verifier@sha256:<digest> \
--certificate-identity "https://github.com/Hodeitek/hodeishield-attest-verifier/.github/workflows/image.yml@refs/tags/<TAG>" \
--certificate-oidc-issuer https://token.actions.githubusercontent.comReplace <digest> and <TAG> with the image digest and the release tag. Then
run it like the commands above, without the script and the bash -c wrapper:
docker run --rm -v "$PWD":/work:ro \
ghcr.io/hodeitek/hodeishield-attest-verifier@sha256:<digest> \
--attestation att.json --jwks jwks.json \
--expect-slug talmaren-payments --expect-issuer https://app.hodeishield.comTo check that the script inside is the one the release signed, compare its
sha256 with the one in the release's SHA256SUMS (see
Verifying a release):
docker run --rm --entrypoint sha256sum \
ghcr.io/hodeitek/hodeishield-attest-verifier@sha256:<digest> \
/usr/local/bin/verify-attestation.shThe image label com.hodeitek.verifier.script-sha256 records the same value
(docker inspect). Treat the label as information: the signed digest and this
command are the evidence. The release workflow also checks the script against
the release's signed SHA256SUMS before it builds the image, and again inside
the build. The image carries a signed SBOM; fetch and check it with
gh attestation verify oci://ghcr.io/hodeitek/hodeishield-attest-verifier@sha256:<digest> --repo Hodeitek/hodeishield-attest-verifier --predicate-type https://spdx.dev/Document/v2.3.
The SBOM is the only attestation; without --predicate-type, gh looks for
SLSA provenance and reports HTTP 404.
The signature proves which workflow built the image and when, not that the
image can be rebuilt bit for bit: the Debian packages are taken from the
archive on the day of the build.
The live check does what this section says on
every push and daily: it takes the digest of the latest release's image, runs
cosign verify and gh attestation verify on it, and runs the production
example above in it.
Posture mode (the default, as above):
| Code | Meaning |
|---|---|
| 0 | Verified. The signature holds and every requested check passed. |
| 1 | Check failed. Something did not hold. Do not rely on the document. |
| 2 | Could not check. Missing tool, an openssl that cannot do ML-DSA, unreadable input, unknown key, a key set whose hs_retired_at is malformed. This is not a statement about the document — do not read it as failure. |
A document generated at or after the retirement of the key that signed it also
exits 1, with a FAIL line starting retired_key: by policy, a retired key stays
published and is marked with hs_retired_at in its key set (see
Current signing keys).
The signature may be genuine, but the document is not one to rely on, and a
fresh copy signed by the same key would fail the same way. Its last line is
VERIFICATION FAILED, not EXPIRED.
A genuine document that is only out of date also exits 1: it is not one to rely
on. Its last line tells it apart from a tampered or invalid one. It starts with
EXPIRED — the signature is valid and gives the date the document expired and
the command to fetch a new one. Any other failure ends with
VERIFICATION FAILED — … Do not rely on this document. The EXPIRED line only
appears when the signature verified and age or expiry was the only problem.
--status-list mode (revocation, see below):
| Code | Meaning |
|---|---|
| 0 | Good. If a document was given, its posture checks passed and it is not revoked. |
| 1 | Revoked, or the posture check of the document you gave failed. |
| 2 | Could not check. Bad flags, a missing tool, or a status list that could not be fetched or read. |
| 3 | Unknown. The list was obtained but does not verify, is stale, was rolled back, names its own signer, or was issued at or after the retirement of its key (retired_key). This is neither "good" nor "revoked". |
Never test $? -ne 0 and treat the result as one outcome. In --status-list
mode that collapses "revoked" (1), "could not check" (2) and "unknown" (3) into
the same branch, and the one you most need to tell apart from a clean result is
the one you would lose. Conflating these is the single most common way to
misread a verifier. They are kept apart deliberately, everywhere in the script.
A signed status list says whether the key that signed a document, or the organisation it was issued for, has been withdrawn. It is a separate document with a separate key set, and it is optional: checking it only adds information. Verify the attestation and its revocation status in one run:
curl -fsS https://app.hodeishield.com/api/public/attest/status -o status.json
curl -fsS https://app.hodeishield.com/api/public/attest/status-keys -o status-jwks.json
bash scripts/attest/verify-attestation.sh --status-list \
--status status.json --status-keys status-jwks.json \
--attestation att.json --jwks jwks.json \
--expect-slug talmaren-payments --expect-issuer https://app.hodeishield.com--jwks and --status-keys are different key sets and are never
interchangeable. With a document, the list is always applied to the document's
own kid; --check-kid KID (a kid, or a usage error) adds a kid to look up and
never replaces it. In the same way the list is always applied to the document's
own slug at its own generatedAt; --check-subject and --check-generated-at
add a subject to look up and never replace it. --status, --status-keys, --check-kid, --check-subject,
--check-generated-at and --min-seq need --status-list: without it they are
a usage error, exit 2. The script reads at most 2,000,000 bytes of the status list,
1,000,000 of the status key set and 4,096 of a decoded protected header (a real
one is about 100 bytes, the live list under 6,000 and the key set under 3,000);
a larger status list, key set or status list header is not read: UNKNOWN
(exit 3). A larger attestation header is a failed check (exit 1,
header_too_large). The limits are fixed in the script. What the list can
and cannot tell you is in
§7.1 of the verification document.
The checks print as PASS, WARN and FAIL lines, in numbered sections, with
the last line giving the verdict. What the document says (its overall band
and, for each framework, its name and band, plus the subject, generatedAt,
lastCheckedAt and visibility) is printed in an "Attested content" block just
before the verdict, and only when the run ends VERIFIED (or GOOD in
--status-list mode). A document that is tampered, expired, revoked, of unknown
status, or that could not be checked shows none of it: the output says
attested content withheld: this document did not verify instead.
The full signed posture JSON is printed only with --raw, under the same rule.
Bands, framework names and the raw posture are never shown for a document that
does not verify, even with --raw.
A run that does not verify still prints what is needed to see why. These are failure reasons, not attested content:
- the check lines, the protected header and the
kid(and the kid derived from the key bytes, when they differ); - the envelope identifiers
docVersion,iss,kid,jtiandnonce; generatedAtandexpiresAt, with the age, on every run (and the issued and next-update times of a status list);- the subject slug: in the expected-and-found slug message, in the
curlhint under anEXPIREDverdict, and on thesubjectHashline in--status-listmode; - the visibility
gated, in a redaction failure message; - the words "nothing is attested", in the
PASSline for a nulloverallBand.
Every value taken from a document, key set or status list, and every value
given on the command line that a message prints back (--expect-slug,
--expect-issuer, --expect-nonce, the URLs of --status and --status-keys,
an unknown argument), and what curl says about a failed fetch, is printed with
control characters, and any byte outside printable ASCII, as a visible \xHH
escape, so an edited document cannot forge lines such as a VERIFIED verdict.
The "Attested content" block is the one place that shows text another way,
because it is only printed for a document that verified: a character that is not
printable is shown as ? (not as \xHH), and any other text, such as a label
with a non-ASCII character, is printed as UTF-8, whatever the terminal encoding.
The check lines and messages above never do that.
--json prints one JSON object on stdout and nothing else (no text on stdout
or stderr), with the same exit codes. Add --json to any other options,
anywhere in the arguments (an argument error is reported as JSON too); --help
and --version keep printing their text.
bash scripts/attest/verify-attestation.sh --json \
--attestation att.json --jwks jwks.json --expect-slug acme --expect-issuer https://issuer.exampleThe object has the verdict (verified, expired, failed,
could_not_check, good, revoked or unknown), the reason code that
decided the exit code, every check with its code and result, and, only when
the exit code is 0, the attested content. The values it reads from a
document that did not verify are under unverified, and are not established.
Test the exit code first. The fields, their stability and examples are in
docs/security/json-output.md.
A verifier that has only ever printed PASS has told you nothing. Each of these
must exit 1, and you should run them before trusting a VERIFIED:
# 1. Tamper with a signed field.
python3 -I -c "import json;d=json.load(open('att.json'));\
d['attestation']['claims']['overallBand']='advanced';\
json.dump(d,open('att-tampered.json','w'))"
bash scripts/attest/verify-attestation.sh --attestation att-tampered.json \
--jwks jwks.json --expect-slug talmaren-payments ; echo "exit=$?" # 1
# 2. Ask for the wrong subject.
bash scripts/attest/verify-attestation.sh --attestation att.json \
--jwks jwks.json --expect-slug not-talmaren-payments ; echo "exit=$?" # 1
# 3. Add a member the signature does not cover.
python3 -I -c "import json;d=json.load(open('att.json'));\
d['attestation']['claims']['note']='not signed';\
json.dump(d,open('att-extra.json','w'))"
bash scripts/attest/verify-attestation.sh --attestation att-extra.json \
--jwks jwks.json --expect-slug talmaren-payments ; echo "exit=$?" # 1The third case fails as unsigned_member: the signature covers a fixed set of
members, so anything else in the JSON is a statement nobody signed, and the
verifier rejects it rather than ignoring it.
docs/security/attest-verification.md §4.7 adds more, including a
header-reordering case and an issuer substitution. To run the full offline
rejection suite (keys generated at test time, no network), use
bash tests/run.sh.
Read §6 of the verification document before relying on any result. It is the section that matters most, and it is longer than the commands on purpose.
The short form, so it is on this page too:
It proves that the document was signed by the holder of the published key, that not one bit has changed since, that the timestamps inside it were not altered after signing (the key holder can sign any timestamp — see §6), and that the signature is post-quantum (ML-DSA-65, FIPS 204).
It does not prove that the claims inside are true. The maturity bands are
our scoring of evidence supplied by the organisation — a signed document
containing a wrong claim is a signed wrong claim. It does not prove the key
belongs to Hodeitek (you are trusting the Web PKI for that one binding — pin the
key's kid, see Pinning the key). It does not prove anything about accredited certification bodies.
And §6 item 8 states, without softening, that the signing key is held in
software rather than in an HSM, and what that means for you.
The fingerprint to pin is the key's kid. It is derived from the public key
bytes: BASE64URL(SHA-256("hodei-shield.attest.kid.v1" || pub)[0..16]), see
§4.2 of the verification document.
The verifier recomputes it from the key bytes, prints it on the kid: line, and
rejects a JWKS whose kid does not match its key. To compute it yourself from
a downloaded jwks.json, one value per key:
python3 -I -c 'import sys,json,base64,hashlib;[print(base64.urlsafe_b64encode(hashlib.sha256(b"hodei-shield.attest.kid.v1"+base64.urlsafe_b64decode(k["pub"]+"="*(-len(k["pub"])%4))).digest()[:16]).decode().rstrip("=")) for k in json.load(open(sys.argv[1]))["keys"]]' jwks.jsonCompare the output with the kid you pinned. The current values are listed in
docs/security/keys.md; the current attestation key is
roeFReafBOA_WF3cqfilHA.
Let the verifier do the comparison with --expect-kid:
bash scripts/attest/verify-attestation.sh --attestation att.json --jwks jwks.json \
--expect-slug talmaren-payments --expect-issuer https://app.hodeishield.com \
--expect-kid <the kid you pinned>- The run exits 1 with a
FAILline,unexpected_kid, naming the kid found and the kids expected, unless the key that signed has that kid. The comparison is with the kid recomputed from the key bytes, never with a label in the document or the JWKS, so a key relabelled to the kid you pinned does not pass. - Repeat the option to pin two kids during a key rotation overlap
(
--expect-kid <old> --expect-kid <new>): the key may have either. - A value that is not the shape of a kid (22 base64url characters) is a usage error, exit 2.
- It is not
--check-kid.--check-kidlooks a kid up in a revocation status list;--expect-kidrequires the attestation's signing key to be one you pinned. It does not apply to the key that signs the status list, which has no pin option in this release.
The key set comes from the issuer's web host, so on its own it proves nothing
about who published the key. A release of this repository from v1.4.0 publishes
a key statement, keys-statement.json, signed with Sigstore by the release
workflow (the decision record). --anchor-file
checks that statement and requires it to list the key that signed your document,
by a channel other than the issuer's web host. Download the statement and its
bundle from the release you trust (not from the issuer's host):
gh release download <TAG> -R Hodeitek/hodeishield-attest-verifier \
-p keys-statement.json -p keys-statement.json.sigstore.json
bash scripts/attest/verify-attestation.sh --attestation att.json --jwks jwks.json \
--expect-slug talmaren-payments --expect-issuer https://app.hodeishield.com \
--anchor-file keys-statement.json- It needs cosign
3.1.3 or later, for this option only. cosign may contact the Sigstore TUF
repository to refresh its trust root. The bundle is read from
keys-statement.json.sigstore.jsonnext to the statement, or from the file given with--anchor-bundle. - The identity cosign must see is fixed in the script (this repository's release
workflow at a tag
vN.N.N, issued by GitHub Actions). No option or environment variable changes it. - The bundle must be exactly a Sigstore bundle v0.3, as the release publishes it; any other shape, such as cosign's legacy bundle format, is refused before cosign runs (exit 2). cosign is then asked a second time for the exact identity read from the bundle's certificate, so the release tag below comes from the certificate cosign verified. That certificate must also carry this repository's numeric GitHub ID (Fulcio's Source Repository Identifier), so a repository that took over the name is not accepted.
- The anchor accepts a statement signed for any
v*tag thatrelease.ymlof this repository built, published or not: the workflow signs when the tag is pushed. See "Consequences" in the decision record. - Exit 2, "anchor could not be checked", when cosign is missing or older, the
bundle is not v0.3, or the statement does not verify (wrong identity, altered
statement, unreadable bundle, no trust root), or is not a well-formed statement. This is by design:
a statement that does not verify says nothing about the attestation, just as a
wrong key document does not. The run never ends in
VERIFIEDthen, and the message says which cause it found. - A statement from a release older than the verifier is refused (exit 2): the
release tag is read from the verified certificate and compared with the
script's own version (
--version), so an old, genuinely signed statement cannot stand in for the current one. A tag that cannot be read is exit 2 too. The check has a limit: an OLD verifier can still be served a statement as old as itself. That is why security fixes ship in a new release and you should run the latest release of your major version, and why--status-list, which revokes a compromised key unconditionally, remains the path for a key compromise. - Exit 1, with a
FAILline, when the statement verifies and does not list the key that signed, lists it under another role, retires it differently from the key set'shs_retired_at, or names another issuer than the document'siss. With--status-list, the same questions about the status-list key leave the revocation status unknown (exit 3). - With
--pub-b64urlthere is no JWK, but the kid is still recomputed from the key bytes, so the statement is still asked about it. There is no key set, so there is nohs_retired_atto compare; a retirement in the statement still applies to the document'sgeneratedAt. - A release made before a rotation does not list the new key: update the verifier.
scripts/attest/verify-attestation.shis the same script we keep, byte for byte, at the same path in Hodeitek's private repository, where CI checks that the two copies never diverge. The HodeiShield service itself does not run this script: it verifies with its own TypeScript implementation of the same format, which is why an independent implementation you can read is worth having. Every release publishes aSHA256SUMSfor the script and a Sigstore keyless signature bundle; see Verifying a release.docs/security/attest-verification.mdis a redacted public edition of an internal document. The redactions are itemised at the bottom of that file, so you can judge them rather than take them on faith. No limitation was removed, shortened or softened; §3 (verifying an unrelated transport root CA) is omitted because it depends on files you do not have.- References in the script's comments to paths like
app/src/lib/attest/…are provenance — they say where a constant or a rule came from. They are not steps you are expected to follow. The same holds for references todocs/architecture/specs/…(the revocation design document) in the script's comments: that document is not public, and nothing the script prints points to it. Where a message sends you to a document, it is §7 or §7.1 of the public one, which covers what a verifier needs.
The verifier checks two document formats, and no other version of either:
| Verifier version | Attestation | Status list |
|---|---|---|
| every release so far (v1.0.0 to v1.2.1) | attest.attestation.v1 |
attest.statuslist.v1 |
- A change to either format that older verifiers cannot read is announced at least 90 days before it takes effect.
- Each major version gets security fixes for five years from its first release (1.x until 2031-09-29), in a new release of that major version; see Supported versions. Update to the latest release of your major version before relying on a result, and see the changelog for what each release changed.
- A document with a format version the verifier does not know is never read as
an older format, and is never verified. An attestation whose
docVersionor posture version is not the one above stops the run with exit 2 ("could not check", the canonical encoder refuses it). A status list with anotherdocVersionisunknown, exit 3.
Download these eight files from the GitHub release you are using:
verify-attestation.sh, keys-statement.json, verify-attestation.sh.spdx.json,
SHA256SUMS, SHA256SUMS.sigstore.json, verify-attestation.sh.sigstore.json,
keys-statement.json.sigstore.json and verify-attestation.sh.spdx.json.sigstore.json.
Then:
sha256sum -c SHA256SUMS
cosign verify-blob --bundle verify-attestation.sh.sigstore.json \
--certificate-identity "https://github.com/Hodeitek/hodeishield-attest-verifier/.github/workflows/release.yml@refs/tags/<TAG>" \
--certificate-oidc-issuer https://token.actions.githubusercontent.com \
verify-attestation.sh
cosign verify-blob --bundle SHA256SUMS.sigstore.json \
--certificate-identity "https://github.com/Hodeitek/hodeishield-attest-verifier/.github/workflows/release.yml@refs/tags/<TAG>" \
--certificate-oidc-issuer https://token.actions.githubusercontent.com \
SHA256SUMS
cosign verify-blob --bundle keys-statement.json.sigstore.json \
--certificate-identity "https://github.com/Hodeitek/hodeishield-attest-verifier/.github/workflows/release.yml@refs/tags/<TAG>" \
--certificate-oidc-issuer https://token.actions.githubusercontent.com \
keys-statement.json
cosign verify-blob --bundle verify-attestation.sh.spdx.json.sigstore.json \
--certificate-identity "https://github.com/Hodeitek/hodeishield-attest-verifier/.github/workflows/release.yml@refs/tags/<TAG>" \
--certificate-oidc-issuer https://token.actions.githubusercontent.com \
verify-attestation.sh.spdx.jsonReplace <TAG> with the release tag. keys-statement.json is the signed list of
the issuer's signing keys; see the
key anchor decision.
verify-attestation.sh.spdx.json is the SBOM of the script, in SPDX 2.3 JSON:
the script's name, version, sha256, licence and supplier, and what it needs to
run (bash 4, OpenSSL 3.5, python3 3.7, and optionally curl and cosign, with the minimum
versions from What you need installed). It is listed
in SHA256SUMS, so the signature above covers it, and it is generated by
.github/scripts/generate-sbom.sh with its
date taken from the tagged commit, so the same tag gives the same file. What changed in each release is in
CHANGELOG.md. This proves that the file was built from
that tag of this repository by its release workflow, and that the signing was
logged in Rekor. The tag itself is signed; GitHub shows it as Verified. It does not prove the verifier is correct. Read it; that is
the reason it is a short, single, readable script.
Planned: an in-browser verifier page, published from this repository only from signed release tags, so that you can check its bytes against the release. See the decision record.
shellcheckruns on the shell scripts.bash tests/run.shruns the offline acceptance suite: every case mints its own documents with a throwaway key and asserts both the exit code and the reason printed. It needs bash, OpenSSL ≥ 3.5 andpython3≥ 3.7, and no network. The anchor cases use a test double in place of cosign.bash tests/sbom.shgenerates the release SBOM for the current script and checks it: valid SPDX JSON, the script's sha256 and version, the licence, the requirements with the minimums above, and identical bytes on a second run.bash tests/vectors.shruns the published, versioned test vectors intests/vectors/v1/: fixed documents, TEST-ONLY keys, a fixed--nowand the expected exit code and reason for each, so you can check another verifier against them.tests/run.shruns them too; seetests/vectors/v1/README.md. The few cases that use real Sigstore bundles need cosign ≥ 3.1.3 and the network; where cosign is missing they are reported as skipped, never as passed.bash tests/vectors.sh --jsonruns every vector again with--jsonand checks the object instead of the text;tests/run.shruns it too.bash tests/mutants.shbuilds a copy of the verifier whose signature check always passes and shows the vectors that depend only on that check (signature_only) are accepted by it, so a verifier with a disabled signature check cannot pass the suite.tests/run.shruns it too;VERIFIER_SKIP_MUTANTS=1 bash tests/run.shskips it locally. CI always runs it and fails if that variable is set there.- A separate workflow runs
tools/mldsa-vectors, a Rust harness that checks the ML-DSA-65 primitive ofaws-lc-rsagainst the signature verdicts in the test vectors, when it, the vectors or the script change. It is a test of a candidate library for a future port, not part of the verifier; see its README. - Two jobs check that the verifier exits 2 ("could not check"), never 1, when
its
opensslcannot do ML-DSA: one with an OpenSSL older than 3.5, one with real LibreSSL in a digest-pinned Alpine image. bash tests/container.shextracts both container commands from this README and runs them as written (live example, plus offline rejection and revocation cases); the "Container route" workflow does so with Docker and Podman.- A pull request that changes the Dockerfile builds the signed-image
candidate without pushing it and runs
tests/image-smoke.shin it (--help, a non-root user, and the publishedvalid-detachedvector). - On every push, pull request and once a day (the "Live check" badge above),
bash tests/live.shruns the verifier against the productiontalmaren-paymentsattestation and the status list, exactly as above. A network outage is reported as a warning; a document that does not verify, a revoked or unknown status, or a 404 on the example is a failure.
If the verifier cannot run or cannot check (exit 2, missing or old tooling, a container route problem), open a public issue with the usage problem template. Do not paste confidential data.
If any check here fails, or if you find a discrepancy between what a document claims and what you can independently establish:
security@hodeitek.com — include the artefact, the exact command, and the full output. A verification failure you can reproduce is a security report and we will treat it as one. You can also report it privately through GitHub (Security tab, Report a vulnerability); see SECURITY.md.
Licensed under the Apache License 2.0. Copyright 2026 Hodeitek S.L.; see NOTICE. It is a verifier; you should be free to read it, run it, modify it and integrate it into your own due-diligence tooling without asking.
HodeiShield® and Hodeitek® are registered trademarks of Hodeitek S.L.; the license grants no rights to them (Apache-2.0 §6).
To contribute, see CONTRIBUTING.md (commits carry a DCO sign-off). To report a vulnerability, see SECURITY.md.
- hodeishield.com — HodeiShield.
- app.hodeishield.com — the HodeiShield application; where the attestations, keys and status list are served.
- hodeitek.com — Hodeitek, who publishes this repository.
