DEV Community

Lee HeeJun
Lee HeeJun

Posted on

Building RFC 3161 Layer 2 Verification for AI Decision Evidence

Most developers think timestamping means sending a request to a Time Stamping Authority and storing the response.

Something like this:

POST /tsa
Content-Type: application/timestamp-query

<binary timestamp request>
Enter fullscreen mode Exit fullscreen mode

But that is not verification.

That is only asking for a timestamp.

If you want to use a timestamp as evidence, you need to verify what came back.

And in RFC 3161, that means dealing with CMS SignedData.

The problem

An RFC 3161 timestamp response is not just a date.

It is a cryptographic structure.

Inside the response, there is a timestamp token. That token is usually a CMS SignedData object containing TSTInfo, signer information, signed attributes, certificates, and a signature.

If you want to treat the timestamp as evidence, you need to answer several questions:

  1. Does the timestamp token refer to the payload I actually submitted?
  2. Does the message imprint match my payload digest?
  3. Which certificate signed the timestamp token?
  4. Is the CMS signature valid?
  5. Was the signer certificate valid for timestamping?
  6. Can this verification be repeated later by another party?

Most basic integrations stop too early.

They parse the response, extract the generated time, store the token, and assume that is enough.

For a normal application timestamp, that may be acceptable.

For an evidence system, it is not.

What I am building

I am building AURORA, a cryptographic evidence layer for AI decisions.

The goal is not to decide whether an AI decision was fair, legal, or correct.

The goal is narrower:

preserve verifiable evidence of what was recorded.

A sealed AI decision record can include:

  • SHA-256 record hash
  • RSA digital signature
  • RFC 3161 timestamp token
  • public verification URL
  • downloadable evidence bundle

The timestamp layer forced me to go deeper into RFC 3161 and CMS verification than I originally expected.

The code snippets below are simplified for explanation, but the verification boundaries are the same.

Layer 1: Parsing the timestamp response

The first layer is relatively straightforward.

You parse the timestamp response, extract the timestamp token, and read the embedded TSTInfo structure.

Conceptually:

tsr = tsp.TimeStampResp.load(token_der)

timestamp_token = tsr["time_stamp_token"]
signed_data = timestamp_token["content"]

tst_info = tsp.TSTInfo.load(
    signed_data["encap_content_info"]["content"].parsed.dump()
)
Enter fullscreen mode Exit fullscreen mode

Then you check the message imprint.

message_imprint = tst_info["message_imprint"]
hashed_message = message_imprint["hashed_message"].native

if hashed_message != expected_digest:
    raise ValueError("Timestamp imprint does not match payload digest")
Enter fullscreen mode Exit fullscreen mode

This confirms that the timestamp token refers to the digest you expected.

That matters because the TSA does not timestamp your original JSON, PDF, or database record directly.

It timestamps a digest.

If the digest in TSTInfo does not match your payload digest, the token is not evidence for your record.

But Layer 1 is still not enough.

It proves that the token contains the expected imprint.

It does not fully prove that the CMS signature is valid.

Layer 2: Verifying CMS SignedData

The harder part is verifying the CMS SignedData structure itself.

This is where the integration becomes less like an API call and more like applied cryptography.

A timestamp token is a signed object.

To verify it properly, you need to inspect the signer, the signed attributes, the encapsulated content, and the certificate usage.

1. Do not assume certs[0] is the signer

One easy mistake is assuming that the first certificate inside the CMS certificate set is the signer certificate.

That is unsafe.

CMS does not guarantee that certs[0] is the signer.

The signer must be selected using the SignerInfo identifier.

Conceptually:

def select_signer_certificate(certs, signer_info):
    signer_id = signer_info["sid"]

    for cert in certs:
        if cert_matches_signer_id(cert, signer_id):
            return cert

    raise ValueError("Signer certificate not found")
Enter fullscreen mode Exit fullscreen mode

The signer can be identified by issuer and serial number, or by subject key identifier.

If you choose the wrong certificate, verification fails.

Worse, you may build an implementation that appears to work only because one provider happens to order certificates in a convenient way.

That is not a verification strategy.

That is an accident.

2. Verify the message-digest signed attribute

CMS signed attributes usually include a message-digest attribute.

This digest must match the digest of the encapsulated TSTInfo content.

Conceptually:

expected_digest = extract_message_digest_attr(signed_attrs)
actual_digest = sha256(tst_info_der).digest()

if expected_digest != actual_digest:
    raise ValueError("CMS message-digest does not match TSTInfo")
Enter fullscreen mode Exit fullscreen mode

This step is easy to misunderstand.

The signature is not simply over your original payload.

In this structure, the signature is over the signed attributes.

Those signed attributes include a digest of the encapsulated content.

So the chain is:

original payload
→ payload digest
→ TSTInfo message imprint
→ TSTInfo DER
→ CMS signedAttrs message-digest
→ CMS signature
Enter fullscreen mode Exit fullscreen mode

Each boundary matters.

If one layer is skipped, the evidence chain becomes weaker.

3. Handle the signed attributes encoding correctly

This was one of the most annoying parts.

CMS signedAttrs are encoded with an IMPLICIT context-specific tag on the wire.

But for signature verification, the signed attributes need to be verified as a SET OF attributes.

In simplified form:

signed_attrs_for_verification = b"\x31" + signed_attrs_wire[1:]
Enter fullscreen mode Exit fullscreen mode

That small encoding detail can break verification for hours.

The data may look logically correct.

The parsed object may look correct.

The certificate may be correct.

The digest may be correct.

But if the exact bytes passed into signature verification do not match the expected DER encoding, the signature fails.

This is one of the places where “I parsed the object” and “I verified the evidence” become very different things.

4. Verify the CMS signature

Once the signer certificate is selected and the signed attributes are encoded correctly, the signature can be verified using the signer certificate’s public key.

Conceptually:

public_key.verify(
    signature_bytes,
    signed_attrs_for_verification,
    padding.PKCS1v15(),
    hash_algorithm,
)
Enter fullscreen mode Exit fullscreen mode

If this succeeds, the CMS signature over the signed attributes is valid.

If it fails, the timestamp token should not be treated as verified evidence.

At this stage, you are no longer merely trusting that the TSA returned something that looks valid.

You are checking the cryptographic structure yourself.

5. Check certificate usage

A valid signature is still not the entire story.

The certificate should also be valid for timestamping.

For RFC 3161 timestamping, that usually means checking for the id-kp-timeStamping extended key usage.

Conceptually:

eku = cert.extensions.get_extension_for_class(
    x509.ExtendedKeyUsage
).value

if ExtendedKeyUsageOID.TIME_STAMPING not in eku:
    raise ValueError("Certificate is not valid for timestamping")
Enter fullscreen mode Exit fullscreen mode

Without this check, you may verify a signature from a certificate that was not intended to issue timestamp tokens.

For an evidence layer, that distinction matters.

The question is not only:

“Was this signed?”

The better question is:

“Was this signed by the right kind of certificate, for the right kind of purpose, over the right content?”

Why this matters for AI decision evidence

AURORA is built around AI decision records.

A record might describe a loan decision, an insurance claim decision, a hiring review, a moderation outcome, a risk score, or another system-generated decision event.

For internal analytics, a normal database log may be enough.

For audit evidence, it is weaker.

A database timestamp says:

“This is what the database currently says.”

A cryptographic timestamp says:

“This digest existed at this time, according to an external timestamp authority.”

A verified cryptographic timestamp says:

“We checked that the timestamp token, imprint, signature, signer certificate, and evidence chain are internally consistent.”

That is a stronger evidence boundary.

What AURORA does not claim

AURORA does not determine whether an AI decision was fair.

It does not determine whether a decision was ethical.

It does not replace legal review.

It does not guarantee regulatory compliance.

Those are separate questions.

AURORA focuses on evidence preservation.

It tries to answer a narrower but important question:

“What exactly was recorded, and can that record be verified later?”

Key takeaways

The main lessons from implementing this layer:

  1. A timestamp response is not the same as a verified timestamp.
  2. RFC 3161 timestamp tokens contain CMS SignedData.
  3. The message imprint must match the payload digest.
  4. The CMS message-digest signed attribute must match TSTInfo.
  5. The signer certificate should be selected by SignerInfo, not by array position.
  6. signedAttrs encoding details matter.
  7. The signer certificate should be valid for timestamping.
  8. Evidence systems should expose verification metadata, not hide it.

The result

AURORA’s verification layer is designed to verify RFC 3161 timestamp evidence instead of blindly trusting that a TSA response is valid.

That timestamp layer sits alongside:

  • SHA-256 record hashing
  • RSA signatures
  • public verification pages
  • downloadable evidence bundles

The broader goal is simple:

AI decision records should be verifiable later.

Not just logged.

Verified.

Project: https://aurora-audit.com

Sample PDF: https://aurora-audit.com/sample-audit.pdf

Have you implemented RFC 3161, CMS SignedData, or timestamp verification before?

I would be interested in hearing what edge cases you ran into.

Top comments (1)

Collapse
 
jakkrow profile image
Lee HeeJun

Additional note:

I intentionally separated “timestamp received” from “timestamp verified” in this post.

For normal application logging, storing the TSA response may be enough.

For evidence systems, I think the verification boundary needs to be explicit: imprint check, signedAttrs digest, signer certificate selection, signature verification, and timestamping EKU.

That distinction is what pushed me to build this layer into AURORA.