feat(headers): 3161-ttc and 3161-ctt timestamp tokens, carried and bound, not validated (RFC 9921) (#217) - #225
Merged
Conversation
…und, not validated (RFC 9921) (#217) CoseHeaders::get3161Ttc() reads label 269 from the protected bucket only and get3161Ctt() reads label 270 from the unprotected bucket only, each as the DER bytes of the RFC 3161 TimeStampToken it wraps; a token in the other bucket, a value that is not a byte string and an empty byte string are rejected, as RFC 9921 §3.1 and §3.2 place the parameters. Cose\Structure\Timestamp holds the three new classes. MessageImprint is the structure of RFC 3161 §2.4.1 and the bytes each mode hashes into it: the payload without its CBOR head for TTC, the CBOR-encoded signature field of a COSE_Sign1 or the CBOR-encoded signatures field of a COSE_Sign for CTT, with the RFC 9054 hash algorithms mapped to their OIDs and toDER() for the TimeStampReq the application builds. TimeStampToken::fromDER() is a minimal parser of the CMS SignedData down to the TSTInfo (imprint, policy, serial number, genTime, nonce) with the content types and the version checked and nothing verified. TimestampBinding is the check of §4, matchesTtc(), matchesCtt() and matches(), the token's hash algorithm resolved through the application's Manager and required to be a Hash, so that a SHA-1 token fails even when SHA-1 is registered for x5t. The TSA's CMS signature, certificate chain and policy are the application's, as the chain of x5chain is. The two tokens of RFC 9921 Appendix A are vendored under tests/fixtures/rfc9921/. The imprints of §3.1.1 and §3.1.2 are reproduced over the COSE_Sign1 and COSE_Sign of RFC 9052 Appendix C, whose signatures verify with key "11", and the Appendix A.1 token binds to its payload. The Appendix A.2 token does not bind to its own message: its imprint is the SHA-256 of the error output of diag2cbor.rb on the line-folded in.diag of the draft's example generator, not of the signature field; the test suite reproduces that byte for byte and the fixtures' README has the account. No erratum exists at this time. tests/RfcReferencesTest.php also takes the assertArrayNotHasKey() that Rector had been asking for on 4.9.x, so that the Rector job passes and the test jobs run.
Spomky
force-pushed
the
feat/issue-217-rfc3161-timestamps
branch
from
September 13, 2026 17:07
b7b8483 to
9e2c853
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Target branch: 4.9.x
Resolves issue #217 (Closes #217)
Part of #202
What
The two header parameters of RFC 9921,
3161-ttc(269) and3161-ctt(270), each carrying an RFC 3161TimeStampToken, read from the bucket the RFC mandates and bound to the message the way §4 requires. Everything is additive: two constants, two accessors, one new namespace with three classes; no existing public signature changes, no new dependency (the ASN.1 layer is pki-framework's, already required).CoseHeadersget3161Ttc(): ?string— protected bucket only (§3.2 "MUST"); a label 269 in the unprotected bucket, alone or as a copy, rejects the message: a token the signature does not cover proves nothing about what was signed (§1.1, the transparency use case is why the mode exists).get3161Ctt(): ?string— unprotected bucket only (§3.1 "MUST"); a label 270 in the protected bucket rejects the message: a token over the signature cannot be under the signature.nullwhen absent; a value that is not a byte string, and an empty byte string, are rejected; indefinite-length byte strings are read. Nothing inside the bytes is parsed by the accessors.LABEL_3161_TTC,LABEL_3161_CTT.Cose\Structure\TimestampMessageImprintSEQUENCE { hashAlgorithm, hashedMessage }, and the bytes each mode hashes into it.ttcInput($payload)/ttc(Hash, $payload): the payload bytes, "This does not include the bstr wrapping" (§3.2).cttInput($message)/ctt(Hash, $message): the CBOR-encodedsignaturefield of aCOSE_Sign1, head included ("including the heading bytes 0x5840", §3.1.1), or the CBOR-encodedsignaturesarray of aCOSE_Sign(§3.1.2), as the message carries them (an indefinite-length signature hashes with its framing).hashAlgorithmOid()/hashAlgorithmIdentifier()map the RFC 9054 algorithms to their OIDs (RFC 5754, RFC 8702; SHA-1 listed so a SHA-1 token is refused as SHA-1, SHA-256/64 has none).toASN1()/toDER()for theTimeStampReqthe application builds,fromASN1()/fromDER()to read one back (parameters absent or NULL per RFC 5754 §2, anything else rejected),equals()withhash_equals(). The senders' constructors takeHash, notFilterOnlyHash: the imprint stands for the data.TimeStampTokenfromDER(): the CMSContentInfomust beid-signedData, theeContentTypeid-ct-TSTInfo, theTSTInfoversion 1; trailing bytes at either level rejected; every decode failure is anInvalidArgumentException. ReadsgetMessageImprint(),getPolicy(),getSerialNumber()(decimal string, 160-bit serials),getGenTime()(UTCDateTimeImmutable),getNonce()(found among the optional fields by type);getTstInfo()is the decodedSequencefor the rest,toDER()the bytes as carried. Verifies nothing.TimestampBindingcreate(Manager).matchesTtc(CoseHeaders, $payload),matchesCtt(CoseHeaders, $message),matches(CoseHeaders, $message, ?$detachedPayload)(every token the message carries, each under its own rule; a message with neither is "not a timestamped message", an exception, not afalsethat would read as a failed check),tokenMatches(TimeStampToken, $input)for callers holding both. The token's OID → RFC 9054 identifier →Manager→ must be aHash: an unknown OID, an unregistered identifier, and SHA-1 (FilterOnlyHash) each throw naming the OID; a mismatch isfalseviahash_equals().Acceptance criteria of #217
hashedMessageinside the committed TST for both modes and both structures.Rfc9921ExamplesTest: §3.1.1 (CTT,COSE_Sign1) gives44c2419d…and §3.1.2 (CTT,COSE_Sign) gives803fada2…, over the RFC 9052 Appendix C.2.1 and C.1.1 messages, whose signatures are first checked to verify with key "11" so the inputs are the genuine ones; Appendix A.1 (TTC): the message is rebuilt from the appendix, round-tripped through the wire, the token read out of the protected bucket, decoded to the printed policy/serial/genTime, and its imprint09e638d4…binds to the payload. Appendix A.2 (CTT) does not bind, and that is the RFC's slip, not the library's: see below.CoseHeadersTest(+8: both structures, alone and as a copy, non-bstr and empty values, indefinite-length) andTimestampBindingTest::aTokenInTheWrongBucketIsRejectedBeforeAnyBinding.TSTInfoimprint does not match the message failsmatches()— other payload, CBOR-wrapped payload, one-byte-different signature, theCOSE_Sign1imprint against theCOSE_Sign, a swapped token in a message carrying both modes, wrong-length digests (TimestampBindingTest).FilterOnlyHash), SHA-512 tokens checked with SHA-512, detached payloads supplied by the caller.iat/exp: what the signer claims."examples/18-rfc3161-timestamps.phphashes the payload, prints theTimeStampReq(built with pki-framework's ASN.1 types, byte-identical to the request behind Appendix A.1), inserts the canned A.1 token and binds it; then the CTT flow over a fresh signature with an unsigned stand-inTSTInfo(no fixture can cover a randomized ECDSA signature), what each mode proves, both misplacements, a SHA-1 token, the A.2 token, and what is left to a CMS implementation.RFC 9921 Appendix A.2 is inconsistent with §3.1.1, and the cause is identified
The committed
3161-ctttoken carries the imprintdd9471ef…; §3.1.1 computes44c2419d…= SHA-256(0x5840‖ signature) for the very same message, which this PR reproduces. The draft's source repository shows why:example/ctt/create-example.shextracts the signature withawk '/signature/ {print $4}'and pipes it throughdiag2cbor.rb; commit58407a7b(2025-05) line-foldedin.diag, soawkyields only the first line,h'8eb33e4c…c4d(unterminated);diag2cbor.rbprints a parse error,set -o pipefaildoes not reach into the<(…)substitution, and when commit086f7f79(2025-08-29) regenerated the token,openssl ts -query -datahashed that error text.sha256("*** can't parse h'8eb3…c4d\n*** Expected one of …")=dd9471ef…, reproduced verbatim inRfc9921ExamplesTest(cbor-diag 0.11.8). The A.1 script hashes a literal string and is unaffected; the tokens are byte-identical to the repository's.derfiles. An erratum against Appendix A.2 was reported to the RFC Editor on 2026-09-13 with this account as its notes (listed at https://www.rfc-editor.org/errata/rfc9921 once screened). Full account intests/fixtures/rfc9921/README.md;RELEASES.mdhas the short version.Tests
tests/Structure/Timestamp/:MessageImprintTest(both modes, both structures, indefinite-length input, the seven OIDs both ways, SHAKE parameter-less identifiers, absent SHA-2 parameters, twelve malformed shapes),TimeStampTokenTest(both appendix tokens, eight optional-field combinations around the nonce, 2¹⁶⁰−1 / 2¹²⁸−1 integers, twenty malformed tokens with their messages),TimestampBindingTest,Rfc9921ExamplesTest;TokenBuildingbuilds unsigned tokens (the library reads theTSTInfoonly, so a signed one would test nothing this library does).CoseHeadersTest(+8),RfcReferencesTestknows RFC 9921,ExamplesTestrunsexamples/18.composer validate --strict,composer normalizeclean.4.9.x(RfcReferencesTest:356,assertArrayNotHasKey()), which skipped every test job; the mechanical fix is included so this PR's CI runs.Docs and examples
README (This library implements, chapter table, the "verifies, it does not decide" line),
doc/README.md(intro, chapter table, tests table, references incl. RFC 3161), newdoc/Timestamps.md,RELEASES.md,composer.jsonkeywordRFC9921,examples/18-rfc3161-timestamps.php+ index,tests/fixtures/rfc9921/(two tokens,extract.py, README with provenance and the A.2 account).