Compliance Evidence
A tenant can download, for each compliance pack the deployment licence entitles, one signed document that an auditor verifies offline. The document is a CycloneDX 1.7 bill of materials holding three things taken at the same instant:
- a CBOM of the tenant's live keys and their algorithms;
- the tenant's evaluation against the pack, as CycloneDX attestations;
- a claim about the integrity of the tenant's stored daily evaluation history for that pack.
The deployment signs the document with its statement key, an ML-DSA-65 key, as a JSON Web Signature. The auditor needs only the document and the deployment's verification-keys document. Neither the auditor nor the verification needs access to the platform. The signed content also states whether the deployment ran under a trial or a commercial licence when it issued the document (The licence status).
The tenant hands both over as one evidence package: a ZIP that holds the signed document, a PDF report a person reads, the plain CycloneDX file an inventory tool imports, the verification keys, a README and a checksum list. The report's SHA-256 is inside the signed document, so the one ML-DSA-65 signature also covers the report.
This page describes the package, the format of the signed document, how to download them, how an auditor verifies them, and what they do and do not claim.
The document gates nothing
Like the evaluation it carries, the document blocks no operation and changes no policy. A satisfied AUTOMATED item does not mean the regime is satisfied, and the document says so in its own affirmation.
The evidence package
The package is what the tenant gives an auditor. It is one ZIP per tenant and entitled pack, assembled by the Admin API from a single issuance: one read of the key population, one evaluation, one signature. Its six entries share one root folder, compliance-evidence-<packId>-<referenceDay>/, and are written in this order:
| File | For whom | What it is |
|---|---|---|
report.pdf | People: the auditor, the compliance officer, management | The evaluation rendered as a report in the console's look (below). It is a rendering of the signed document, not a separate statement |
evidence.jose.json | The auditor's verification | The signed document, a JWS (Format). It is the authoritative artefact: byte for byte what exportComplianceEvidence serves for the same issuance |
cbom.cdx.json | Inventory and CBOM tools | The base64url-decoded payload of evidence.jose.json, byte for byte: plain CycloneDX 1.7 JSON that any CycloneDX tool imports without unwrapping a JWS |
verification-keys.json | The auditor's verification | The deployment's verification keys, read after signing; it lists the kid that signed evidence.jose.json |
README.txt | Anyone who opens the ZIP | What each file is and the verification steps, in the report language (UTF-8, LF line ends). Directly after its title line it states the licence status: the signed value and the statement that explains it. It states that SHA256SUMS is not signed |
SHA256SUMS | sha256sum -c | One line per file above, in the coreutils format <lowercase hex SHA-256> <name> |
The files have fixed names, so a script addresses them without parsing a name; the pack and the day are in the root folder's name. The root folder exists only as the prefix of the six entry names: the ZIP holds exactly six entries, with no directory entry and no signature of its own. Every entry is DEFLATE-compressed and carries the generation instant, in UTC, as its time.
How one signature covers the report
The signed CycloneDX document carries, in metadata.properties, two properties that bind the report:
| Property | Value |
|---|---|
ankasecure:report:sha256 | The lowercase hex SHA-256 of the exact report.pdf bytes in the package |
ankasecure:report:language | The report's language, en or es |
The platform renders the report first, hashes it, writes the hash into the document and only then has the document signed. The one ML-DSA-65 signature over the document therefore also covers the report: an auditor who checks the signature and then compares the hash knows the report is the one the deployment issued. The PDF itself carries no signature. There is no circularity: the report names the document's serialNumber, which is fixed before the document is built, and names nothing that signing produces.
A document downloaded on its own with exportComplianceEvidence carries neither property: that request renders no report, and a property naming a report that was never shipped would be false.
SHA256SUMS is not signed. It detects a damaged or incomplete copy and nothing more; integrity comes from the signature and the hash inside the signed document.
The report
report.pdf is laid out like the console, with the ANKASecure logo and the console's typefaces (Space Grotesk, Inter, JetBrains Mono) embedded, and is printed on white. It holds:
- Cover: the pack title,
packIdandpackVersion, the tenant id, the UTC reference day, the generation instant, the document'sserialNumber, the producing release and a licence status row whose value is the signed licence status,TRIALorLICENSED, printed literally. Below the facts, the cover states in a sentence what that status means: that the evidence was issued while the deployment ran under a trial licence, or under a commercial licence of the deployment. The statement is tagged content, part of the logical structure, so assistive technology reads it aloud. - Summary: the score, or
NEUTRAL, the number of items per result, and the three statements of what the evidence does not claim, including "a satisfiedAUTOMATEDcontrol does not mean the regime is satisfied". - One row per evaluated item (control or calendar entry): id, statement, obligation, severity, verification mode, result and reason in words, effective day, risk class and whether the item is scored. A
FAILrow lists every key identifier the item names, with no cap; the 16 MiB bound on the document already bounds the population. The identifiers are printed as one wrapped paragraph, separated by commas, not as one list item per key: at the bound an item can name tens of thousands of keys, and one structure element per key would multiply the memory the report needs. - The keys no catalogue resolves, printed the same way as one paragraph, and the chain claim: verified with its head, broken at a sequence number and check, or no history.
- Remediation plan: the tenant's remediation plan for the pack. Each action with its key, kind, status in words, owner, target date, risk acceptance (reason and
until), its last execution with its outcome counts, and any migration declaration; and every attestation in force, with its item, statement, attester, expiry, and each evidenceuriandsha256. The section reports workflow state the tenant recorded: a risk acceptance or an attestation is printed beside the evaluation and never changes a result or the score. - How to verify: the package files and the steps below, and the statement that
evidence.jose.jsonis authoritative and the report is a rendering of it.
Because the remediation section is part of report.pdf, the signed ankasecure:report:sha256 covers it like the rest of the report, and the package still holds exactly the six files above. Free text the tenant wrote (owner, reason, statement, attester, evidence links) is escaped and printed as literal text; no evidence link is fetched or made clickable.
Every page carries the licence status again in its running footer, as a line naming the status and its value, so a page printed or forwarded on its own still says whether it comes from trial evidence. The footer is a page-margin artifact outside the logical structure, which text extraction still reads; the cover statement is the tagged copy. The report carries no watermark: a diagonal mark would cover report content, and in a tagged PDF it would be an artifact a screen reader skips.
A result is always written in words; colour never carries it alone. The report holds no signature, no kid, no key material and no link. It is a tagged PDF conforming to PDF/UA-1: it declares its language, carries a logical structure for assistive technology, and its logo has alternate text.
The report is rendered on the server without network access and without reading any file outside its own templates and fonts. A key identifier the tenant chose is escaped and printed as literal text, and the PDF contains no JavaScript, open action, launch action, URI action or embedded file.
Language
The labels of the report and the text of README.txt follow the request's Accept-Language header: the first language range decides, es gives Spanish, and anything else, an unparseable header or no header gives English. The response's Content-Language, the ankasecure:report:language property and the audit row state the language used.
Pack titles and control statements stay in English in both languages: they quote the catalogue, which is not translated. In a Spanish report they are tagged as English, so a screen reader pronounces them correctly. The licence status label and statement, on the cover, in the footer and in README.txt, follow the language; the status value, TRIAL or LICENSED, is printed as signed in both. cbom.cdx.json and evidence.jose.json are the same in both languages except for the language property, the report hash, and the per-issuance values (serialNumber, generation instant) that differ on every download anyway.
Format
| Layer | Standard | What it carries |
|---|---|---|
| Envelope | JSON Web Signature, General JSON serialization (RFC 7515 §7.2.1), exactly one signature | payload and signatures[{protected, signature}], every value base64url |
| Signature | ML-DSA-65 (FIPS 204), pure, empty context, as JOSE registers it in RFC 9964 (May 2026) | The deployment's attribution of the exact payload bytes |
| Payload | CycloneDX 1.7, which ECMA-424 2nd edition (December 2025) standardizes | The CBOM, the pack as a standard with its requirements, and the attestations |
The media type of the download is application/jose+json. The payload is the base64url encoding of the exact CycloneDX bytes the platform produced and validated, so an auditor reads and validates those same bytes. No re-serialization is involved: every signed value is a string, so a tool that re-indents or re-orders the outer JSON object does not break verification.
Why a JWS and not a CycloneDX signature
CycloneDX 1.7 defines a signature member based on the JSON Signature Format, whose algorithm list has no ML-DSA; a proposal to add it is open in the CycloneDX specification (issue 682). JOSE registers ML-DSA-65 in RFC 9964, so off-the-shelf JOSE tooling can verify it. The CycloneDX document therefore claims no signature of its own: it has no signature member and its affirmation's signatory has none.
Downloading
| Method | Endpoint | operationId | Scope |
|---|---|---|---|
| GET | /api/v3/admin/tenants/{tenantId}/compliance-evidence/{packId}/package | exportComplianceEvidencePackage | admin.tenant.analytics.read |
| GET | /api/v3/admin/tenants/{tenantId}/compliance-evidence/{packId} | exportComplianceEvidence | admin.tenant.analytics.read |
| GET | /api/v3/admin/tenants/{tenantId}/compliance-evidence/verification-keys | getComplianceEvidenceVerificationKeys | admin.tenant.analytics.read |
| GET | /api/v3/admin/platform/compliance-evidence/verification-keys | getPlatformComplianceEvidenceVerificationKeys | platform.analytics.read |
In the admin console, the tenant's pack evaluation page offers one action, Download evidence package, which requests the package in the console's current language. The platform's compliance evaluations page offers Download verification keys, unchanged. The console saves each file exactly as the server sent it, under the name the server gives it, and never opens, decodes or verifies it. The signed document and the tenant-plane verification keys remain available as separate API operations, for an integration that wants only the machine-readable artefacts; the console does not call them.
- The package answers
200application/zipwithContent-Disposition: attachment; filename="compliance-evidence-<packId>-<referenceDay>.zip",Content-Language(enores) andCache-Control: no-store. It takes the optionalAccept-Languageheader (Language). Like the document, it is generated on request and not retained: each download is a new issuance with a new serial number, a new report and a new signature. The body is always labelledapplication/zip; a client that sendsAccept: application/json,*/*orapplication/zip, application/problem+jsonis served the same ZIP and the same refusals, and any otherAcceptgets406with no body. Before the response is sent, the Admin API checks that the keys document it read names thekidthat signed and the sameinstallationIdas the signature's header; a mismatch is a502, never a package whose keys cannot verify it. - The evidence answers
200withContent-Disposition: attachment; filename="compliance-evidence-<packId>-<referenceDay>.jose.json"andCache-Control: no-store. It is generated on request and is not retained: a second download produces a new document with a new serial number, signed again. The body is always labelledapplication/jose+json; a client that sendsAccept: application/jsonis served the same document and the same refusals, because a General JSON JWS is a JSON object. - The verification keys answer
200withContent-Disposition: attachment; filename="compliance-evidence-verification-keys-<installationId>.json". The body is the deployment's statement verification-keys document, relayed unchanged:installationId,activeKeyRef,keys[{keyRef, algorithm, publicKeySpki, active}]anddeclaredLimits. It is the same document on both planes and for every tenant, because one key signs every document the deployment issues.
The packs are those the evaluation covers. Evidence attests today's evaluation, so a pack the licence does not entitle (a locked pack) answers 403 Compliance Pack Not Licensed on both exports, and nothing is rendered, signed or recorded. The verification keys are served on every licence status, EXPIRED included, so a document issued while the pack was licensed still verifies offline after it leaves the licence. Every refusal is application/problem+json:
| Status | Problem | When |
|---|---|---|
400 | Validation | tenantId is not a UUID, or packId does not match ^[A-Z][A-Z0-9_]{1,63}$, decided before the scope check |
401 | Unauthorized | No token, or an invalid one |
403 | Forbidden | The scope is missing; on the platform plane, the token is not seated in the platform tenant |
403 | Compliance Pack Not Licensed | Package and evidence only: the pack is in the catalogue and the licence does not entitle it; decided when the licence is read, after the tenant, identifier, scope and catalogue checks (and, for the package, the busy check), with nothing rendered or signed and no audit row. The detail names no pack and no licence status |
404 | Resource Not Found | The path names a tenant the caller does not belong to, the same answer whether or not it exists |
404 | Compliance Pack Not Found | Package and evidence only: the pack is not one of this release, decided before the licence is read |
406 | none, no body | Package only: the Accept header admits none of the operation's media types |
409 | License Installation Identity Unavailable | The licence server cannot establish which installation it is, so nothing names a guessed one |
422 | Compliance Evidence Too Large | Package and evidence only: the document would exceed the 16 MiB bound; decided before the report is rendered, before validation and before any call to the licence server |
500 | Internal Server Error | Package and evidence only: the produced document failed CycloneDX 1.7 validation and was not signed, the report could not be rendered (before any call to the licence server), or the audit row could not be written; nothing is served |
502 | Upstream Service Unavailable | The licence, the signature or, for the package, the verification keys could not be obtained from the licence server, for any reason other than the 409, including the signing deadline (30 seconds by default) expiring, a signature whose protected header names no kid or carries an alg, typ or cty other than the values below, and a keys document that does not list the signing kid or names another installationId; no partial document or package |
503 | Compliance Evidence Package Busy, with Retry-After: 15 | Package only: the service instance that received the request is already assembling another package. Decided after the pack identifier is checked and before any call to the licence server; nothing is rendered, signed or recorded. Retry after the stated delay |
Every refusal and every failure above writes no audit row.
Package assembly is serialized per service instance. A request received while another package is being assembled on the same instance is not queued: it is answered at once with 503 compliance-evidence-package-busy and Retry-After: 15, and should be retried after that delay. The check is made after the tenant and the pack identifier are checked and before the licence is read, so a busy instance answers 503 even for a pack the licence does not entitle. The signed document alone (exportComplianceEvidence) renders nothing and is not serialized.
The envelope
A document has this shape (values abridged):
{
"payload": "eyJib21Gb3JtYXQiOiJDeWNsb25lRFgiLCJzcGVjVmVyc2lvbiI6IjEuNyIsInNlcmlhbE51bWJlciI6InVybjp1dWlkOjliMmU2YzQxLTdkM2EtNGY4NS1iMWM5LTJhNmUwZDRmOGM3MyIsInZlcnNpb24iOjE…",
"signatures": [
{
"protected": "eyJhbGciOiJNTC1EU0EtNjUiLCJraWQiOiJ1c2FnZS1zdGF0ZW1lbnQta2V5LXYxIiwidHlwIjoidm5kLmFua2FzZWN1cmUuY29tcGxpYW5jZS1ldmlkZW5jZStqd3MiLCJjdHkiOiJ2bmQuY3ljbG9uZWR4K2pzb24iLCJpbnN0YWxsYXRpb25JZCI6IjNmOWMyYTdlLTViMWQtNGM4ZS05YTBmLTZkMmU0YjdjMWE5NSJ9",
"signature": "q3RmYk9wZl9oT2FfZ0VfR3pYc2hRWmxKb0ZxS1dDN0pVb3dmV2Y5cGp0QkdfSWtMQkVwRjJkTnk…"
}
]
}
The protected header has exactly five members, and the licence server composes it; nothing the document's requester sends can choose them:
| Member | Value |
|---|---|
alg | ML-DSA-65 |
kid | the statement-key version that signed, as the verification-keys document names it in keyRef (for example usage-statement-key-v1) |
typ | vnd.ankasecure.compliance-evidence+jws, the explicit type that keeps this document from being accepted as any other JWS (RFC 8725 §3.11) |
cty | vnd.cyclonedx+json: the payload is a CycloneDX JSON document |
installationId | the installation that signed, equal to the verification-keys document's installationId |
The signature is computed over the JWS signing input of RFC 7515 §5.1, the ASCII bytes protected '.' payload exactly as they appear in the document.
One key signs two families of artefact, and their inputs cannot be confused. The same statement key signs the deployment's monthly usage statements. A statement's signed preimage begins with the decimal element count 4:, while an evidence signing input consists only of base64url characters and one ., and contains no :. No byte string is a valid input of both, so a signature over one can never verify as the other. The licence server also refuses to sign any body that is not a CycloneDX 1.7 document.
The CycloneDX document
The document is produced and serialized by the official CycloneDX Java library and validated against the CycloneDX 1.7 schema before it is signed. The size bound is checked first, so a document above it is refused without being validated. A document that fails validation is never signed.
| Element | Content |
|---|---|
bomFormat, specVersion, serialNumber, version | CycloneDX, 1.7, urn:uuid:<random UUID>, 1 |
metadata | The generation instant, and properties in the ankasecure: namespace naming the tenant, the pack, its packVersion, the reference day, the score (or NEUTRAL), the producing release, the licence status (licence:status, always) and, when the platform can state it, the tenant's key custody (keyProtection:assurance, keyProtection:family, keyProtection:plane). In a package only, report:sha256 and report:language (above) |
algorithm components | One per distinct algorithm among the live keys: primitive, algorithmFamily, parameterSetIdentifier, the OID, classicalSecurityLevel and nistQuantumSecurityLevel (rules below) |
related-crypto-material components | One per live key: type secret-key (authenticated encryption or MAC), private-key (any other catalogued algorithm) or key (an algorithm no catalogue resolves), id = the key identifier, state, algorithmRef to its algorithm component, creation and expiry dates, and, when the tenant's key custody is known, securedBy.mechanism HSM or Software |
dependencies | For a composite algorithm, its two component algorithms |
definitions.standards | The pack as one standard, bom-ref <packId>@<packVersion>, with one requirement per control (<packId>@<packVersion>#<controlId>) and per calendar entry (<packId>@<packVersion>#calendar[<n>]) |
declarations.assessors | One assessor: the deployment itself, thirdParty: false |
declarations.attestations | One attestation whose map has one entry per evaluated item: conformance.score 1 for PASS, 0 for FAIL, no score otherwise. The item's result and reason, its effective day and its risk class are carried in its evidence, and every key it names is referenced by the bom-ref of that key's component. An item's conformance states its own result; whether the item counts towards the pack score is the evaluation's rule, so a tenant with no live key carries a metadata score of NEUTRAL even when its evidence-integrity item has a conformance |
| The chain claim | A second claim stating the result of verifying the tenant's daily history chain for the pack, and its head when it has rows (below) |
declarations.affirmation | The statements of what the document does not claim; its signatory names the organization and refers, by an externalReference of type other, to the verification-keys read; no reference is typed digital-signature, because nothing inside the document claims a signature |
Which keys. The tenant's live keys, exactly as the evaluation reads them: ACTIVE, EXPIRED and SUSPENDED, composite keys with their components. One read of the key population feeds both the CBOM and the evaluation, so every key an attestation names is a component of the same document. A revoked or archived key is not listed, and no other tenant's key ever is. The CycloneDX state is active for ACTIVE, suspended for SUSPENDED and deactivated for EXPIRED.
No key bytes. No public key, private key or key material appears anywhere in the document; a key is described by its identifier, its algorithm, its state, its dates and its custody mechanism.
Algorithm attributes. Each value comes from a primary source cited beside it in the platform's algorithm catalogue:
classicalSecurityLevelis the strength in bits from NIST SP 800-57 Part 1 Rev. 5, Table 2:RSA-2048is 112 andX25519is 128.nistQuantumSecurityLevelis the NIST post-quantum security category, where the CycloneDX schema defines 0 as "none of the categories are met". Every classical asymmetric algorithm is 0. A post-quantum or symmetric algorithm takes the category its standard assigns (ML-KEM-768andML-DSA-65are 3,AES-GCM-256is 5). A composite takes its post-quantum component's category (P-256+ML-DSA-65is 3). The stateful hash-based families, LMS and XMSS, carry no category, because NIST SP 800-208 assigns none. No value is derived from the platform's own security-level ordering, which ranksRSA-2048andX25519beside post-quantum algorithms.primitiveis one of the CycloneDX tokens. An RSA or elliptic-curve key, which the catalogue allows for more than one primitive (an RSA key both encrypts and signs), isother, because naming either would misstate the other use. No algorithm is mapped tokey-wrap.oidis present when the algorithm has a registered object identifier. FrodoKEM and Classic McEliece carry the identifiers of the ISO/IEC 18033-2 arc, which ISO/IEC 18033-2:2006/Amd 2:2026 standardises:FRODO-9761.0.18033.2.2.7.5,FRODO-13441.0.18033.2.2.7.6,CMCE-4608961.0.18033.2.2.6.1andCMCE-66881281.0.18033.2.2.6.5. The amendment's text is not publicly available, so the FrodoKEM identifiers are cited from draft-smyslov-lamps-frodokem-certificates-03 and the Classic McEliece identifiers from Bouncy Castle's registration, which the keys the platform generates carry. HQC carries nooid, as does every other algorithm without a registered identifier.
What the CBOM covers. The keys the platform manages and their algorithms. Libraries, protocols, applications and certificates the platform does not see are outside it; in the NIST_CSWP_39 pack that part is the customer-attested control CSWP39.CBOM.ORGANISATION.
The licence status
Since a deployment under a trial licence evaluates every pack and issues evidence for it, signed by the same statement key as licensed evidence, the document states which licence it was issued under. Every document, from either export, carries exactly one metadata.properties entry for it:
| Property | Value | Meaning |
|---|---|---|
ankasecure:licence:status | TRIAL | The deployment ran under a trial licence when the document was issued |
ankasecure:licence:status | LICENSED | The deployment ran under a commercial licence that entitles the pack, a licence in its grace period included |
- Always present, two values only.
LICENSEDis written explicitly, so a document that is silent about its licence is never read as licensed: a verifier refuses a document without the property, with it more than once, or with any other value (step 5). A licence in its grace period still entitles its packs, so it isLICENSED; there is no grace value. AnEXPIREDlicence issues no evidence at all (403Compliance Pack Not Licensed), so it has no value either. - Taken from the read that admitted the pack. The licence is read once per issuance: the status the property states is the status of the same read that decided the pack is entitled. No request parameter can set it.
- Inside the signed content. The property is part of the payload, so changing
TRIALtoLICENSEDfails signature verification. The protected header keeps its five members and none names the licence, and a trial document is signed by the same key, under the samekid, as a licensed one of the same installation. The property, not the key, tells them apart. - A statement about issuance. It records the licence under which the deployment issued the document; it says nothing about the licence afterwards. The audit row does not repeat it: the row holds the SHA-256 of the signed document, which carries it.
- What it discloses. The tenant plane otherwise carries no licence status. Every tenant-plane holder of
admin.tenant.analytics.readwho exports evidence learnsTRIALorLICENSEDfor the deployment, and nothing else about the licence: no licence id, no remaining days, no grace flag, noEXPIREDvalue and no identifier the licence names outside the catalogue.
The report and README.txt print the same value, in the report language, so a script can find it in either:
| Where | English | Spanish |
|---|---|---|
| Report cover, facts row | Licence status | Estado de la licencia |
| Report, every page's footer | Licence status: <value> | Estado de la licencia: <value> |
README.txt, the line after the title | Licence status: <value> — <statement> | Estado de la licencia: <value> — <statement> |
The statement, on the cover and in README.txt, is one of:
| Value | English | Spanish |
|---|---|---|
TRIAL | Trial licence: this evidence was issued while the deployment ran under a trial licence. | Licencia de prueba: esta evidencia se emitió mientras el despliegue funcionaba con una licencia de prueba. |
LICENSED | Licensed: this evidence was issued under a commercial licence of the deployment. | Con licencia: esta evidencia se emitió con una licencia comercial del despliegue. |
The chain claim
Every day the platform stores, per tenant and evaluated pack, one row of evaluation facts, appended to a hash chain under an HMAC key the platform holds; the rows are append-only and are never deleted. At generation, the platform verifies the tenant's chain for the pack from its first row and states the outcome in the document:
| Outcome | What the claim carries |
|---|---|
| Verified | The number of rows and the head: the last row's sequence number, day and row hash |
| Broken | The sequence number of the first row that fails, and the check it failed: its row hash, its link to the previous row, a gap in the sequence, or an unknown canonical generation |
| No history | No stored row exists yet for this tenant and pack; no head |
A broken chain does not stop the document from being issued: the document states the break rather than hiding a measured failure. In the NIST_CSWP_39 pack, the same verification, over every pack chain of the tenant, decides the control CSWP39.EVIDENCE-INTEGRITY.PLATFORM.
What the claim means, and what it does not.
- It is the platform's statement that it verified its own chain. The chain is an HMAC chain, and an auditor does not hold the HMAC key, so an auditor cannot verify the chain itself. What the signature adds is that the deployment made the statement and that nobody changed it afterwards.
- The head is what an auditor can check without the key. Across two documents for the same pack, the later head's sequence number is never lower, and an equal sequence number has an equal row hash. A head that went backwards or changed means the stored history was altered between the two documents.
- Removing the last rows of a chain is not detected from inside the chain. A chain truncated at its tail still verifies. Only a document holding an earlier head reveals it, by the rule above, which is why holding successive documents is worth more than holding one.
Verifying a package offline
The auditor needs only the unpacked ZIP, sha256sum, Python 3 and OpenSSL 3.5 or later; no step reaches the platform. As with the document alone, verification-keys.json is trusted only once it has been compared with a copy obtained out of band, for example from the deployment operator.
- Check the copy. In the package's root folder, run
sha256sum -c SHA256SUMS. Every line must printOK. This detects a damaged or incomplete copy only:SHA256SUMSis not signed, and anyone who changes a file can rewrite it. - Verify the signature of
evidence.jose.jsonagainstverification-keys.json, by steps 2 to 4 of verifying a document offline. - Bind the BOM. Base64url-decode the
payloadofevidence.jose.jsonand require the result to equalcbom.cdx.jsonbyte for byte. -
Bind the report. In
cbom.cdx.json, read themetadata.propertiesentryankasecure:report:sha256and require it to equal the SHA-256 ofreport.pdf.The script below runs steps 1, 3 and 4, and also applies the licence status check of document step 5: it requires exactly one
ankasecure:licence:statusentry,TRIALorLICENSED, and prints it.# Run inside the package's root folder, after step 2 succeeded sha256sum -c SHA256SUMS python3 - <<'EOF' import base64, hashlib, json doc = json.load(open("evidence.jose.json")) payload = base64.urlsafe_b64decode(doc["payload"] + "=" * (-len(doc["payload"]) % 4)) assert payload == open("cbom.cdx.json", "rb").read(), "cbom.cdx.json is not the signed payload" entries = json.loads(payload)["metadata"]["properties"] props = {p["name"]: p["value"] for p in entries} report = hashlib.sha256(open("report.pdf", "rb").read()).hexdigest() assert props["ankasecure:report:sha256"] == report, "report.pdf is not the report the BOM names" status = [p["value"] for p in entries if p["name"] == "ankasecure:licence:status"] assert len(status) == 1 and status[0] in ("TRIAL", "LICENSED"), \ "the BOM does not state exactly one ankasecure:licence:status, TRIAL or LICENSED" print("BOM and report bound to the signature; report language:", props["ankasecure:report:language"]) print("Licence status:", status[0]) EOF
When steps 2 to 4 pass, the one ML-DSA-65 signature covers all three: the signed document, the BOM an inventory tool imports, and the report a person reads. A changed byte in report.pdf fails step 4, even when SHA256SUMS was rewritten to match it; a changed byte in cbom.cdx.json fails step 3; a changed byte in evidence.jose.json fails step 2. README.txt carries no evidence and is covered only by step 1: its licence status line repeats the signed value and proves nothing on its own. Then continue with steps 5 to 7 of the document procedure to read the BOM, the chain claim and, when needed, the issuance record.
A TRIAL package verifies with its own verification-keys.json exactly as a LICENSED one does: the same keys and the same kid. Only the signed property, which the script prints, tells them apart.
Verifying a document offline
The procedure uses only the signed document and the verification-keys document. Any FIPS 204 ML-DSA-65 verifier works; a JOSE library is not required.
- Hold both files: the document (
evidence.jose.jsonin a package, or a.jose.jsonfile downloaded on its own) and the verification-keys document (verification-keys.jsonin a package). - Read the header. Parse the JWS, refusing any JSON object in it, or in the keys document, that repeats a member name, and require exactly one entry in
signatures. Base64url-decode itsprotectedmember and require exactly the five members above, each a JSON string, and no other:algML-DSA-65,typvnd.ankasecure.compliance-evidence+jws,ctyvnd.cyclonedx+json, akid, andinstallationIdequal to the keys document'sinstallationId. Read the keys document'sdeclaredLimitsbefore relying on that comparison. - Select the key whose
keyRefequalskid, and require itsalgorithmto beML-DSA-65. Akidthe keys document does not list, or lists in more than one row, is a verification failure, not a reason to try another key.publicKeySpkiis the base64 (standard alphabet) X.509 SubjectPublicKeyInfo. A JOSE library that implements RFC 9964 takes the raw public key instead, in an AKP JWK{"kty":"AKP","alg":"ML-DSA-65","pub":"<base64url of the raw key>"}: the raw key is the content of the SubjectPublicKeyInfo'ssubjectPublicKey, 1,952 bytes for ML-DSA-65. -
Verify the signature. The signed bytes are the ASCII string
protected '.' payload, taken from the document exactly as they appear. Verify the base64url-decodedsignatureover those bytes with ML-DSA-65, pure, with an empty context. With OpenSSL 3.5 or later:# verification-keys.json and evidence.jose.json: the two files (both are in a package) python3 - <<'EOF' import base64, json doc = json.load(open("evidence.jose.json")) keys = json.load(open("verification-keys.json")) sig = doc["signatures"][0] hdr = json.loads(base64.urlsafe_b64decode(sig["protected"] + "=" * (-len(sig["protected"]) % 4))) key = next(k for k in keys["keys"] if k["keyRef"] == hdr["kid"]) spki = key["publicKeySpki"] with open("pub.pem", "w") as f: f.write("-----BEGIN PUBLIC KEY-----\n") f.write("\n".join(spki[i:i + 64] for i in range(0, len(spki), 64)) + "\n") f.write("-----END PUBLIC KEY-----\n") open("signing-input.bin", "wb").write((sig["protected"] + "." + doc["payload"]).encode("ascii")) open("signature.bin", "wb").write(base64.urlsafe_b64decode(sig["signature"] + "=" * (-len(sig["signature"]) % 4))) EOF openssl pkeyutl -verify -pubin -inkey pub.pem -in signing-input.bin -sigfile signature.binOpenSSL verifies an ML-DSA signature over the raw message, without a prehash, and its default context string is empty (OpenSSL ML-DSA). It prints
Signature Verified Successfullyon success. -
Read the CycloneDX bytes. Base64url-decode
payload: these are the exact CycloneDX 1.7 bytes. Validate them against the official CycloneDX 1.7 schema, then readmetadata, the components,definitions.standardsanddeclarations. Require the licence status:metadata.propertiesholds exactly one entry namedankasecure:licence:status, and its value isTRIALorLICENSED. A document without it, with it more than once, or with any other value fails verification; record the value with the verdict (The licence status). A document at the 16 MiB bound has apayloadof about 22 million characters, above the default maximum string length of some JSON parsers; Jackson's default is 20,000,000 (StreamReadConstraints). Raise that limit before parsing a large document. - Read the chain claim as the platform's statement about its own chain, and, when an earlier document for the same pack is at hand, compare the two heads as above.
- Confirm issuance when needed: the tenant's audit trail holds one row per served document or package, with the SHA-256 of the exact document bytes (below).
The verification keys
The keys document lists the active statement-key version and every retired one, each with its public half. The statement key is ML-DSA-65 only: the licence server refuses to start when the active key or any retired public key is anything else, so every row of the document is ML-DSA-65.
A key version that was active while the deployment could issue evidence is never retired. Evidence documents are not retained, so nothing can show that a version signed none, and a document an auditor holds verifies only while the public half its kid names is still published. A key rotation adds a version and keeps every earlier one in the document. The audit row of each document names the version that signed it, so the versions in use stay on record.
The keys document, its declared limits and the statement scheme it also serves are described in Statement Verification Keys*.
Audit
Each document served writes one admin audit row for the tenant, event type admin.complianceevidence.create, entity type ComplianceEvidence, entity id the document's serialNumber UUID. Its payload holds the packId, packVersion, referenceDay, documentSha256 (the SHA-256 of the exact response body) and signingKeyRef (the header's kid). It holds no key identifier of the tenant, no CycloneDX content and no signature. The document is sent only after the row is recorded; if the row cannot be recorded, the request answers 500 and no document is served. Refusals write no row, and the verification-keys reads write none.
Each package served writes exactly one row of the same event type, entity type and entity id, whose payload action is compliance.evidence.package.issued. It adds three members to those above:
| Member | Value |
|---|---|
documentSha256 | The SHA-256 of evidence.jose.json inside the package, so step 7 of the document procedure holds for the file in a package exactly as for a document downloaded on its own |
reportSha256 | The SHA-256 of report.pdf, equal to the signed ankasecure:report:sha256 property |
reportLanguage | The resolved report language, en or es |
packageSha256 | The SHA-256 of the exact ZIP response body |
As for the document, the ZIP is sent only after the row is recorded, a failed record answers 500 with no body, and the row holds no tenant key identifier, no CycloneDX content and no signature.
Size bound
A document's CycloneDX bytes are bounded at 16 MiB. A key costs about 0.7 KiB of CycloneDX at a typical identifier length and up to about 1.6 KiB with a 128-character identifier and many claims naming it, so the bound admits at least about 10,000 live keys. A tenant beyond it receives 422 Compliance Evidence Too Large and nothing is signed.
The package uses the same bound and adds no other. Its check runs before the report is rendered and before any call to the licence server: the two report properties have a fixed width (64 hexadecimal characters and a two-letter language), so the size of the signed document is known before the report exists. The licence status is already in the document that is measured, so the measured and the signed document have the same length on either status. The whole package is prepared before its first byte is sent, because its audit row carries the SHA-256 of the whole ZIP.
A package whose document is just under the bound takes about 10 seconds to prepare, within the Retry-After: 15 of a busy answer; its report.pdf is about 1 MB and the ZIP about 2 MB.
What the document does not claim
Each statement applies equally to the report in a package, which renders the same document.
- It is not a certification. It reports the platform's state against a pack's
AUTOMATEDitems; a satisfied item does not mean the regime is satisfied, and customer-attested items are listed as not evaluated. - It is the deployment's own assessment. The assessor is the deployment, not a third party.
- The installation binding has a declared limit. The installation identity is generated when the installation is created and is not a machine fingerprint: a restored copy of one installation shares it. The keys document's
declaredLimitsstates this in full. - The chain claim is the platform's word about an HMAC chain an auditor cannot verify (above).
- The time is the deployment's clock. The generation instant and the reference day are the server's; the document carries no time-stamp from an independent authority.
- A signature attributes, it does not make true. The scheme proves that the deployment signed the document. A platform whose own components were compromised could have false content signed; that limit applies equally to the deployment's usage statements.
Related
- Compliance Packs — the packs, the evaluation and its score
- Compliance Remediation Plans — the plan the report's remediation section prints
- NIST CSWP 39 Alignment* — the CBOM and evidence-integrity sections
- CycloneDX CBOM