This page tells a third party how to check an awa release using the published repository, the release's assets and standard tools. It says what each check proves and, just as important, what it does not prove.
Measured with: git 2.45.1.windows.1, OpenSSH OpenSSH_9.7p1
(git-for-windows) and OpenSSH_for_Windows_9.5p2, Bun 1.3.13.
What is signed, and which half is authoritative
A release is signed by two separate mechanisms, one per object:
- The release tag is signed over SSH (
gpg.format=ssh) by an independent ed25519 tag key. This signature is classical-only. - The release digest manifest (
awa-digest-manifest/1) lists the SHA-256 and byte size of every released artifact. It is signed by a detachedawa-envelope/1hybrid signature,COMPSIG-MLDSA65-ECDSA-P256-SHA512(OID1.3.6.1.5.5.7.6.45). The hybrid combines ML-DSA-65 and ECDSA P-256, and both must verify. The signing key belongs to a two-level key chain (awa-key-chain/1): a long-lived root key (awa-root-2026, five-year window) attests short-lived signing keys (one-year window).
The manifest is the authoritative artifact. The tag signature is the weaker, classical half: a verifier who checks only the tag has checked the weaker half. The manifest names the commit of the release tag, and that binds the two halves together.
awa-envelope/1 is not a JWS (RFC 7515). It borrows only the base64url
alphabet and the three-segment shape, its alg is not IANA-registered, and
no third-party library verifies it.
What you need
The commands on this page use only these placeholders:
| Placeholder | Meaning |
|---|---|
<release-tag> |
the release tag name, in a clone of the published repository |
<assets> |
the directory holding the release's downloaded assets |
<file> |
one artifact, as named by an artifacts[].path entry of the manifest |
<manifest> |
the release's digest manifest file |
<manifest>.awa-sig |
the detached awa-envelope/1 signature of the manifest |
chain.json |
the key-chain file shipped as a release asset |
<stem>.spdx.json, <stem>.cdx.json |
the release's SBOM pair, shipped as release assets: SPDX 2.3 and CycloneDX 1.6. <stem> is the npm package name with @ removed and every / replaced by -; for @awacloud/fw the pair is awacloud-fw.spdx.json and awacloud-fw.cdx.json (see § The SBOM pair) |
<archive> |
a download archive: <repo>-<version>.tar.gz or .zip of a package, or awa-public-<commit>.tar.gz or .zip of the public source tree, where <commit> is the first 12 characters of the source commit |
<tree-manifest>, <tree-manifest>.awa-sig |
the public-tree digest manifest and its detached signature, published beside the tree archives |
<release> |
the release identifier: the tag field of <tree-manifest>, also the name of the release's download directory |
<clone> |
a clone of the public repository github.com/awacloud/awa |
<extracted> |
the directory where you extracted a public-tree archive |
<tmp> |
an empty scratch directory |
allowed_signers |
the tag-key file in this directory: allowed_signers |
<path> |
the directory where you saved allowed_signers |
Asset file names other than chain.json, the SBOM pair and the default
.awa-sig suffix are placeholders. The publication gateway fixes the real
names.
The trust anchor comes from SECURITY.md. It is
reproduced here:
{
"rootKid": "d4525f63b6a9d56371841a267496bb1aec5a55f15bc80fbb7ecf0886860b070b",
"rootFingerprint": "SHA256:1FJfY7ap1WNxhBomdJa7GuxaVfFbyA-7fs8IhoYLBws"
}
Rules for the anchor:
- Compare
SHA256:values character for character. Do not compare only a prefix or suffix. SECURITY.mdis authoritative. If this page andSECURITY.mddisagree, stop and trust neither. Report the mismatch through the channelSECURITY.mdnames.
The keys
| Key | Kind | Identity | Validity window | Signs |
|---|---|---|---|---|
awa-tag-2026 (tag key) |
ed25519, OpenSSH | fingerprint SHA256:UTwOcNgvlIYp7WCz2tcMSZdK6hMsUs8mWbb/1GAGROA, principal security@awacloud.com |
— | release tags only (namespace git) |
awa-root-2026 (root) |
COMPSIG-MLDSA65-ECDSA-P256-SHA512 |
kid d4525f63b6a9d56371841a267496bb1aec5a55f15bc80fbb7ecf0886860b070b, fingerprint SHA256:1FJfY7ap1WNxhBomdJa7GuxaVfFbyA-7fs8IhoYLBws |
2026-09-14T16:34:37Z to 2031-09-14T16:34:37Z |
attestations only (purpose attest) |
awa-sign-2026 (signing key) |
COMPSIG-MLDSA65-ECDSA-P256-SHA512 |
kid 257c6a43bfc1567e6928cb48de21b727c435b77ad67f9bbb250ca68530254071 |
2026-09-14T16:37:23Z to 2027-09-14T16:37:23Z |
digest manifests — the release manifests and the public-tree manifest (purpose envelope) |
Not a release key: the
security@channel key is a DIFFERENT key. It is a classical OpenPGP key used to encrypt and sign vulnerability-report correspondence. It signs nothing in the release chain. It shares the addresssecurity@awacloud.comwith the tag key's principal, but it is a different key and must never be used to verify a release. Its fingerprint is published inSECURITY.mdand is not repeated here.
Lane 1 — verify the release tag (git + OpenSSH)
Step A — compare the fingerprint
cut -d' ' -f3- <path>/allowed_signers | ssh-keygen -lf -
Expected: exit 0, and this output:
256 SHA256:UTwOcNgvlIYp7WCz2tcMSZdK6hMsUs8mWbb/1GAGROA no comment (ED25519)
The fingerprint must equal the tag-key fingerprint in § The keys, character
for character. Do not run ssh-keygen -lf on the whole file: it fails with
<path> is not a public key file. (exit 255), because allowed_signers
starts with a principal and options, not a key.
Step B — verify the tag signature
Run this in a clone of the published repository that has the tag:
git -c gpg.ssh.allowedSignersFile=<path>/allowed_signers verify-tag <release-tag>
Expected: exit 0, and a line of this form (shown here as measured with a
throwaway test key, so its principal and fingerprint are not the release
values):
Good "git" signature for security@example.invalid with ED25519 key SHA256:Stg0+5fOMfEbs7i++yS9XYq1LeeuU1EIYAUyWoizruM
For a release, the SHA256: fingerprint on that line must be
SHA256:UTwOcNgvlIYp7WCz2tcMSZdK6hMsUs8mWbb/1GAGROA.
Warning — git trusts the key, not the name.
git verify-tagaccepts a signature when the signing key is listed inallowed_signers. It does not check that the principal name beside the key is the one you expect. This was measured with throwaway keys, on both the git-for-windows and the Windows OpenSSHssh-keygen:
allowed_signersdiffers from the correct file bygit verify-tagexitdecisive output the principal name only (same key) 0Good "git" signature for someone-else@example.invalid with ED25519 key SHA256:…(same fingerprint as the correct file)the key (a different key) 1No principal matched.the namespace (not git)1key is not permitted for use in signature namespace "git"A file whose only change is the principal still verifies. Compare the fingerprint, never the principal alone. These cases are pinned by the key-tool tag-principal integration test,
tools/key-tool/tests/tag-principal.integration.test.ts, in the awa source repository.key-toolcarries no distribution entry, so that path is in neither the published packages nor this repository.
Windows note. On the measured Windows host, git verify-tag succeeded
without any gpg.ssh.program setting. It printed the same line, with the
same exit code, when -c gpg.ssh.program= pointed at the git-for-windows
ssh-keygen or at the Windows OpenSSH ssh-keygen. On that host the
setting was not required. This is a measurement on one host.
What Lane 1 proves: the tag object was signed by the tag key. Nothing more. It says nothing about the released artifacts, and nothing post-quantum.
Lane 2 — artifact digests and the tag binding (standard tools)
The manifest is a JSON file with these fields:
{
"type": "awa-digest-manifest/1",
"commit": "<commit id of the release>",
"tag": "<release-tag>",
"generated": "<ISO-8601 instant>",
"artifacts": [
{ "path": "<file>", "sha256": "<lowercase hex SHA-256>", "bytes": "<size in bytes, a number>" }
]
}
Never re-serialise the manifest. A verifier hashes file bytes, and the signature covers the manifest file exactly as downloaded.
For each entry of artifacts[]:
sha256sum <assets>/<file>
Expected: exit 0, and the first field of the output equals that entry's
sha256; the comparison is the check.
A changed file gives a different digest. In the measurement, flipping one
byte of a throwaway artifact changed its digest to
f720bc63856899503311efaa75a4bc21e4f592983736c4ff3674da18c4b16926.
wc -c < <assets>/<file>
Expected: exit 0, and the printed number equals that entry's bytes.
Then check the binding to the tag, in the clone used for Lane 1:
git rev-parse "<release-tag>^{commit}"
Expected: exit 0, and the printed commit id equals the manifest's commit.
Also check that the manifest's tag
field equals <release-tag>.
What Lane 2 proves without Lane 3: only that the files match a manifest that someone wrote. The manifest is not yet authenticated. Lanes 1 and 2 together show that the tag is genuine and that the files match an unauthenticated manifest naming that tag's commit.
The SBOM pair
Each release ships its package's SBOM as two release assets,
<stem>.spdx.json (SPDX 2.3) and <stem>.cdx.json (CycloneDX 1.6).
- The SBOM files are not signed. Signing them is planned.
- Their SHA-256 and size are listed in the release's digest manifest, as two
artifacts[]entries whosepathis the file name. Check them exactly like any other artifact, with the Lane 2 commands above:sha256sum <assets>/<file>andwc -c < <assets>/<file>. - The digest manifest is the signed artifact. A manifest verified through Lane 3 therefore covers the SBOM files' integrity: a file whose digest and size match its entry is the file the manifest listed when it was signed. Without Lane 3, the match is against an unauthenticated manifest, as for every other artifact.
The manifest covers the bytes of the SBOM files. It says nothing about whether their content is complete or correct.
Download archives
An archive is never signed on its own. It is covered by being listed:
- A package's archives,
<repo>-<version>.tar.gzand<repo>-<version>.zip, are twoartifacts[]entries of that package's release manifest, whosepathis the file name. - The public-tree archives,
awa-public-<commit>.tar.gzandawa-public-<commit>.zip, are twoartifacts[]entries of<tree-manifest>. That manifest'scommitis the source commit, and itstagis the release identifier<release>.
The check is the Lane 2 pair, unchanged: sha256sum <assets>/<archive> and
wc -c < <assets>/<archive>, compared with the archive's entry (sha256,
then bytes).
<tree-manifest> also lists every file of the source tree, by its path
inside the archive's top directory, awa-public-<commit>/. After extracting
the archive (tar -xzf <archive> or unzip <archive>), each such file can
be checked with the same two commands, run from inside that directory:
sha256sum <file> and wc -c < <file>, where <file> is the entry's
path. A shell loop over artifacts[] is yours to write; this page
prescribes none.
commit names the publisher's source commit. The public repository's
history does not carry that commit. It carries one commit per release
instead, tagged awa-public@<release>, and that tag is signed by the tag
key like every release tag: check it with Lane 1, Step B, with
awa-public@<release> as <release-tag>.
The tagged tree is the public source tree of the archives. To check it, extract the tagged tree beside the archive's content and compare the two directories:
git -C <clone> -c core.autocrlf=false archive --prefix=awa-public-<commit>/ awa-public@<release> | tar -x -C <tmp>
diff -r <tmp>/awa-public-<commit> <extracted>/awa-public-<commit>
Expected: exit 0 and no output. core.autocrlf=false keeps git from
rewriting line endings on the way out. Measured before the first release,
with core.autocrlf=true instead: diff -r exits 1 and reports most
files as different.
<tree-manifest> is verified in Lane 3 exactly like a release manifest:
the verify command there takes --manifest <tree-manifest> and
--sig <tree-manifest>.awa-sig in place of the release manifest and its
signature.
The manifest covers the bytes of the archive. It says nothing about what the archive should contain beyond the listed files, and it does not make the archive a substitute for the published package: the npm registry serves its own tarball.
Two builds of the same source commit give identical archive bytes on the publisher's toolchain; this page does not claim you can rebuild them.
Lane 3 — the hybrid signatures (chain replay + envelope)
Lane 3 authenticates the manifest. The anchor values below come from
SECURITY.md (see § What you need).
Replay the key chain against the anchor:
bun cli.ts key-tool chain verify --chain chain.json \
--root-kid d4525f63b6a9d56371841a267496bb1aec5a55f15bc80fbb7ecf0886860b070b \
--root-fingerprint "SHA256:1FJfY7ap1WNxhBomdJa7GuxaVfFbyA-7fs8IhoYLBws" --json
Expected: exit 0, with the decisive line "ok": true,. verified lists
two keys: the root (serial 1, purpose ["attest"]) and the signing key
(serial 2, purpose ["envelope"]), with the kids and windows of § The
keys.
Verify the manifest's envelope signature:
bun cli.ts key-tool verify --manifest <manifest> --sig <manifest>.awa-sig --chain chain.json \
--root-kid d4525f63b6a9d56371841a267496bb1aec5a55f15bc80fbb7ecf0886860b070b \
--root-fingerprint "SHA256:1FJfY7ap1WNxhBomdJa7GuxaVfFbyA-7fs8IhoYLBws" --json
Expected: exit 0, with the decisive field "verdict": "accepted". verify
runs three gates in order: the chain replay against the anchor, the envelope
signature check, then the acceptance rules of § Validity, expiry and the
annual re-sign.
Exit codes of both commands:
| Code | Meaning |
|---|---|
0 |
ok — the chain replays (chain verify), or the verdict is accepted (verify) |
1 |
rejection — a VOID chain, an envelope rejection or any non-accepted verdict (also an unknown verb or a bad flag) |
2 |
configuration error — for example an unreadable or malformed chain file |
Limitation — Lane 3 is not yet runnable from the published repository.
key-toolis the reference verifier, and it is not published: it has no distribution entry. The commands above run only from a checkout of the awa source repository, with Bun (afterbun install, from the checkout's root). There is currently no independently published verifier for the composite signature. A verifier who holds only the published repository can perform Lanes 1 and 2, and cannot yet perform Lane 3.Lanes 1 and 2 alone do not establish the post-quantum guarantee. The tag signature is classical, and without Lane 3 the manifest is unauthenticated.
Reading a failure
verify reports exactly one verdict. The first two come from its first two
gates (tools/key-tool/src/cmd/verify.ts); the rest are the acceptance
verdicts (Verdict in tools/key-tool/src/policy.ts):
| Verdict | Meaning |
|---|---|
accepted |
every gate passed |
chain-void |
the chain does not replay against the anchor (see the rule table below) |
envelope-reject |
the envelope check failed; envelope.step and envelope.reason say where |
unknown |
the signature's kid is not in the verified set |
malformed-instant |
an instant is not YYYY-MM-DDTHH:MM:SSZ |
iat-after-at |
the declared signing instant is after the verification instant |
revoked |
the key was revoked at or before the declared signing instant |
not-yet-valid |
the verification instant is before the key's notBefore |
expired |
the verification instant is after the key's notAfter |
iat-before-window |
the declared signing instant is before the key's notBefore |
purpose |
the object type is not in the key's attested purpose |
Measured examples, on throwaway keys: a manifest with one changed byte gave
envelope-reject at step 3, header.payload.sha256 does not match the supplied payload (exit 1). Verifying one day after the signing key's
notAfter gave expired (exit 1).
When the chain does not replay, chain verify prints ok: false and one or
more errors[] entries { index, rule, detail }. index is the position in
chain.entries; index -1 is the root entry. Rejection is total:
verified is empty whenever ok is false. For example, with the last
character of the root fingerprint changed, the measured result was exit 1
and:
chain error: [-1] R1.fingerprint: root fingerprint != anchor.rootFingerprint
The errors[].rule names:
| Rule | Fires when |
|---|---|
R1.chainType, R1.chainShape |
type is not awa-key-chain/1; root/entries[] missing |
R1.json |
root body is not base64url-encoded UTF-8 JSON |
R1.type, R1.role |
root body is not an attestation; role != "root" |
R1.alg |
root declares an alg/oid pair other than this tool's composite (enforced) |
R1.issuer |
entry.issuer != body.issuer.kid |
R1.selfIssued |
issuer.kid != subject.kid (the root is not self-attested) |
R1.anchorKid |
subject.kid != anchor.rootKid |
R1.publicKey, R1.kid |
publicKey is not base64url; declared kid ≠ sha256(publicKey) |
R1.fingerprint |
"SHA256:"+b64url(sha256(pk)) != anchor.rootFingerprint |
R1.sigEncoding, R1.sig |
sig is not base64url; the root self-signature does not verify |
R1.serial, R1.window |
root serial not an integer ≥ 1; !(notBefore < notAfter) — an absent or unparseable window fires too |
R2.json, R2.type |
entry body undecodable; body type is neither attestation nor revocation |
R2.serial |
serial not an integer strictly greater than the previous serial |
R2.issuer |
entry.issuer != body.issuer.kid, or the issuer is not the anchored root |
R2.sigEncoding, R2.sig |
sig not base64url; signature does not verify under the ROOT key |
R2.role |
attestation role != "signing" |
R2.alg |
entry declares an alg/oid pair other than this tool's composite (enforced) |
R2.publicKey, R2.kid |
publicKey not base64url; declared kid ≠ sha256(publicKey) |
R2.window |
notBefore >= notAfter |
R2.rootLifetime |
notAfter > root.validity.notAfter (enforced) |
R2.reissue |
a second attestation for a kid already in the verified set |
R2.revokeUnknown |
revocation whose subject is not (yet) in the verified set |
R2.revokedAt |
revokedAt is not a parseable ISO-8601 instant |
Validity, expiry and the annual re-sign
The acceptance rule, quoted from the verifier's source:
A signature by key K with declared signing instant
iat, verified at instantat, is accepted iff K is not revoked as ofiat, K's window containsat,iatis not afteratnor before K'snotBefore, and the object type is in K's attested purpose. Consequences: a signature made before a revocation stays verifiable until K'snotAfter(revocation is dated, not retroactive); every signature stops verifying once K's window ends, whatever itsiat(re-signing is periodic — annual under the 1-year signing window);iatis declared by the signer and is not attested, so a back-datediatfrom a stolen key evades revocation only untilnotAfter— the window bounds the exposure.
What this means for a verifier:
awa-sign-2026expires at2027-09-14T16:37:23Z. After that instant, every signature made by it readsexpired, whenever it was made.- Before then, the owner issues a new signing key under
awa-root-2026, re-signs the manifests of the supported releases and republisheschain.json. The new attestation is appended to the chain; the earlier entries stay. - Use the newest
chain.json, the one published with the latest release. - A signing key can never outlive the root (rule
R2.rootLifetime). The root window ends at2031-09-14T16:34:37Z.
This re-sign was rehearsed with throwaway keys: at an instant after the first
signing key's notAfter, the old signature read expired and a signature of
the same manifest by a newly issued key read accepted.
The owner's command sequence for the re-sign is documented in the key-tool
package README, tools/key-tool/README.md, in the awa source repository.
key-tool carries no distribution entry, so that path is in neither the
published packages nor this repository. This page does not reproduce owner commands, and the
business calendar of the re-sign is out of its scope.
Revocation and re-rooting
Revocation is dated against the declared signing instant (iat) and is
not retroactive. A signature made before a revocation stays verifiable
until the key's notAfter. The iat is declared by the signer and is not
attested, so the key's validity window is what bounds the exposure (see the
quoted rule above).
Re-rooting. No chain-internal re-root exists. The chain has no entry that links an old root to a new one. If the root has to be replaced:
- the new anchor is published through the same out-of-band channel as
the original:
SECURITY.md, plus a dated publication event; - every signature made before the re-root is re-verified only against the old anchor's frozen chain, never against the new anchor.
Scope of this page
Covered: release tags, digest manifests (release and public-tree), the envelope key chain, and through the digest manifests the integrity of the SBOM pair and of the download archives.
Not covered:
- verification of the
security@channel key itself (seeSECURITY.md); - deployment keys and SSH host keys. This project does not publish them at this time;
- a signature per archive — none exists; an archive's completeness.