Skip to content

About

Verify a HodeiShield post-quantum posture attestation offline. No credentials, no account, no cooperation from us required.

Topics

Resources

Contributing

Security policy

Stars

0 stars

Watchers

0 watching

Forks

Repository files navigation

HodeiShield Attest Verifier, by Hodeitek

Verify a HodeiShield attestation yourself

Live check against production CI Release Licence: Apache-2.0

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.

The short version

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.com

talmaren-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.

Without a terminal

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.

What you are verifying

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.

What you need installed

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.

Run it without installing anything

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.json
docker 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.com

The 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.json
docker 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 podman instead of docker. 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.

A signed image

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.com

Replace <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.com

To 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.sh

The 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.

Exit codes — the distinction matters

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.

Revocation

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.

What the output shows

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, jti and nonce;
  • generatedAt and expiresAt, 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 curl hint under an EXPIRED verdict, and on the subjectHash line in --status-list mode;
  • the visibility gated, in a redaction failure message;
  • the words "nothing is attested", in the PASS line for a null overallBand.

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.

Machine-readable output

--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.example

The 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.

Convince yourself it can fail

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=$?"   # 1

The 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.

What a VERIFIED proves — and what it does not

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.

Pinning the key

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.json

Compare 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 FAIL line, 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-kid looks a kid up in a revocation status list; --expect-kid requires 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.

Anchor the key

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.json next 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 that release.yml of 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 VERIFIED then, 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 FAIL line, when the statement verifies and does not list the key that signed, lists it under another role, retires it differently from the key set's hs_retired_at, or names another issuer than the document's iss. With --status-list, the same questions about the status-list key leave the revocation status unknown (exit 3).
  • With --pub-b64url there 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 no hs_retired_at to compare; a retirement in the statement still applies to the document's generatedAt.
  • A release made before a rotation does not list the new key: update the verifier.

Where this comes from, and what is redacted

  • scripts/attest/verify-attestation.sh is 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 a SHA256SUMS for the script and a Sigstore keyless signature bundle; see Verifying a release.
  • docs/security/attest-verification.md is 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 to docs/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.

Compatibility

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 docVersion or 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 another docVersion is unknown, exit 3.

Verifying a release

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.json

Replace <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.

Tests and CI

  • shellcheck runs on the shell scripts.
  • bash tests/run.sh runs 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 and python3 ≥ 3.7, and no network. The anchor cases use a test double in place of cosign.
  • bash tests/sbom.sh generates 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.sh runs the published, versioned test vectors in tests/vectors/v1/: fixed documents, TEST-ONLY keys, a fixed --now and the expected exit code and reason for each, so you can check another verifier against them. tests/run.sh runs them too; see tests/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 --json runs every vector again with --json and checks the object instead of the text; tests/run.sh runs it too.
  • bash tests/mutants.sh builds 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.sh runs it too; VERIFIER_SKIP_MUTANTS=1 bash tests/run.sh skips 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 of aws-lc-rs against 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 openssl cannot do ML-DSA: one with an OpenSSL older than 3.5, one with real LibreSSL in a digest-pinned Alpine image.
  • bash tests/container.sh extracts 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.sh in it (--help, a non-root user, and the published valid-detached vector).
  • On every push, pull request and once a day (the "Live check" badge above), bash tests/live.sh runs the verifier against the production talmaren-payments attestation 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.

Reporting a problem

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.

Licence

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.

About

About

Verify a HodeiShield post-quantum posture attestation offline. No credentials, no account, no cooperation from us required.

Topics

Resources

Contributing

Security policy

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages