<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Lee HeeJun</title>
    <description>The latest articles on DEV Community by Lee HeeJun (@jakkrow).</description>
    <link>https://dev.to/jakkrow</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3979832%2F6db8365f-4a36-4454-9c4b-c56311931833.png</url>
      <title>DEV Community: Lee HeeJun</title>
      <link>https://dev.to/jakkrow</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/jakkrow"/>
    <language>en</language>
    <item>
      <title>The day my database refused to return a row I had just written</title>
      <dc:creator>Lee HeeJun</dc:creator>
      <pubDate>Sun, 14 Jun 2026 08:01:22 +0000</pubDate>
      <link>https://dev.to/jakkrow/the-day-my-database-refused-to-return-a-row-i-had-just-written-3d64</link>
      <guid>https://dev.to/jakkrow/the-day-my-database-refused-to-return-a-row-i-had-just-written-3d64</guid>
      <description>&lt;p&gt;For a while, my multi-tenant SaaS isolated tenants the way most apps do: every query carried a WHERE organization_id = :current_org clause, enforced in the application layer. It works. Until it doesn't — one missing filter, one new endpoint that forgets the convention, one ORM relationship that loads more than you expected, and one tenant can see another tenant's data.&lt;br&gt;
For most products that's a bug. For a product whose entire value proposition is being the trustworthy custodian of someone else's records, it's existential. "We filter by organization in the code, trust us" is not a sentence I wanted to say to a security reviewer.&lt;br&gt;
So I moved isolation down a layer — into the database itself, with Postgres Row-Level Security (RLS). This is a short write-up of how that rollout went, and specifically about the moment it appeared to break, which turned out to be the moment it proved it was working.&lt;br&gt;
The shape of the design&lt;br&gt;
The idea behind RLS is simple: instead of trusting every query to filter correctly, you attach a policy to the table, and Postgres refuses to return rows that don't match — no matter what the query says.&lt;br&gt;
The policy needs to know who is asking. The common pattern is to push the current tenant into a session-scoped runtime parameter (a GUC), and have the policy read it:&lt;br&gt;
sql-- set per request, transaction-scoped&lt;br&gt;
SELECT set_config('app.current_org_id', :org_id, true);&lt;/p&gt;

&lt;p&gt;-- policy on each tenant table&lt;br&gt;
CREATE POLICY tenant_isolation ON some_table&lt;br&gt;
  FOR ALL&lt;br&gt;
  USING (organization_id = current_setting('app.current_org_id', true))&lt;br&gt;
  WITH CHECK (organization_id = current_setting('app.current_org_id', true));&lt;br&gt;
Two design choices matter here.&lt;br&gt;
First, fail closed. I wrapped the GUC lookup in a helper that returns NULL when the parameter is missing, rather than throwing or defaulting to something permissive. Because organization_id = NULL is never true in SQL, a request that forgets to set its tenant context sees zero rows — not everything. The absence of identity is treated as "you are no one," not "you are everyone."&lt;br&gt;
Second, the app connects as a non-superuser, non-BYPASSRLS role. RLS politely does nothing for superusers and table owners. If your app's database role can bypass the very policies you wrote, you've built a very expensive no-op. The runtime role has exactly the grants it needs and nothing more; privileged maintenance happens through a separate role.&lt;br&gt;
The chicken-and-egg table&lt;br&gt;
One table fought back: the membership table that maps users to organizations.&lt;br&gt;
Every request resolves its org_id by querying that table ("which org does this user belong to?"). But the org GUC isn't set yet at that point — resolving it is the whole reason we're reading the table. If the membership table's read policy depends on the org GUC, you deadlock yourself out of every request.&lt;br&gt;
The fix is to give that one table a policy keyed on the user identity (set earlier in the request) rather than the org identity (set later). The user GUC is available before org resolution; the org GUC is not. Writes to the membership table are blocked entirely on the app path and go through the privileged role, because membership changes are not something a normal tenant request should ever do directly.&lt;br&gt;
Small thing, but if you get the ordering wrong, nothing works, and the failure looks terrifyingly total.&lt;br&gt;
The moment it "broke"&lt;br&gt;
Here's the part I actually wanted to write about.&lt;br&gt;
I enabled the policies table by table, smoke-testing each one. Most went green. Then I enabled it on a table behind a create endpoint — make a thing, read it back, return it to the user — and it threw:&lt;br&gt;
sqlalchemy.exc.InvalidRequestError: Could not refresh instance ''&lt;br&gt;
The handler had just inserted a row, committed, and then tried to read it back. And the read came back empty. The database refused to return a row the same request had created seconds earlier.&lt;br&gt;
For about thirty seconds this looks like a catastrophic regression. It is the opposite. It is the system working exactly as designed.&lt;br&gt;
The cause: transaction-scoped GUCs (set_config(..., true), equivalent to SET LOCAL) are cleared on COMMIT. The pattern was:&lt;br&gt;
pythondb.add(row)&lt;br&gt;
db.commit()          # &amp;lt;- SET LOCAL tenant context is wiped here&lt;br&gt;
db.refresh(row)      # &amp;lt;- new transaction, no GUC, policy sees NULL -&amp;gt; 0 rows&lt;br&gt;
After the commit, the next statement runs in a fresh transaction with no tenant context. The fail-closed helper returns NULL. The policy matches nothing. The refresh() finds no row — including the one we just wrote — and errors.&lt;br&gt;
This is the fail-closed design proving itself in production. A session that hadn't re-established who it was got shown nothing. If the isolation had been sloppy — defaulting to "see everything" when context is missing — this would have silently succeeded, and I'd have shipped a system that returns data to sessions with no identity. The error was the receipt.&lt;br&gt;
The fix is to re-inject the tenant context immediately after committing, so legitimate work continues to carry its identity across the commit boundary:&lt;br&gt;
pythondef commit_and_restore(db, ctx):&lt;br&gt;
    db.commit()&lt;br&gt;
    if ctx:  # tenant request: re-apply GUCs for the next transaction&lt;br&gt;
        set_config(db, "app.current_user_id", ctx.user_id)&lt;br&gt;
        set_config(db, "app.current_org_id", ctx.org_id)&lt;br&gt;
    # privileged/admin sessions have no ctx and pass straight through&lt;br&gt;
I routed every commit-then-keep-querying handler through that helper, and watched the same endpoints go green — this time because isolation was working, not because it was absent.&lt;br&gt;
What I took away&lt;/p&gt;

&lt;p&gt;Fail-closed is only useful if you can tell it apart from a bug. The "Could not refresh" error and a genuine isolation hole would look completely different in production, but in the moment they feel identical. Knowing your design well enough to read the failure correctly is the skill.&lt;br&gt;
Test the negative, eventually. Watching your own tenant's data appear is necessary but not sufficient. The real property is that another tenant's data does not appear. Single-tenant smoke tests can't prove that; the fail-closed refresh error was, ironically, the strongest live evidence I got that the empty case behaves correctly.&lt;br&gt;
Defense in depth means not trusting your own code. RLS doesn't replace careful query-writing. It's the layer that holds when the careful query-writing eventually slips — and over a long enough timeline, it slips.&lt;/p&gt;

&lt;p&gt;The most reassuring thing a security layer can do is refuse you when you haven't proven who you are. Even when "you" is the request that wrote the row.&lt;/p&gt;

</description>
      <category>postgres</category>
      <category>security</category>
      <category>saas</category>
      <category>backend</category>
    </item>
    <item>
      <title>Building a verifier that doesn't ask you to trust the issuer</title>
      <dc:creator>Lee HeeJun</dc:creator>
      <pubDate>Sat, 13 Jun 2026 17:56:54 +0000</pubDate>
      <link>https://dev.to/jakkrow/building-a-verifier-that-doesnt-ask-you-to-trust-the-issuer-2736</link>
      <guid>https://dev.to/jakkrow/building-a-verifier-that-doesnt-ask-you-to-trust-the-issuer-2736</guid>
      <description>&lt;h1&gt;
  
  
  Building an Offline Verifier for AI Decision Evidence
&lt;/h1&gt;

&lt;p&gt;Most audit-log systems have a quiet dependency baked into them:&lt;/p&gt;

&lt;p&gt;to believe the log, you have to believe the system that wrote it.&lt;/p&gt;

&lt;p&gt;A cloud provider sealing its own logs is still the cloud provider vouching for itself. A SaaS platform exporting its own audit trail is still asking the reviewer to trust the platform that produced the evidence.&lt;/p&gt;

&lt;p&gt;That is not always wrong. But it is not independent.&lt;/p&gt;

&lt;p&gt;If the only way to check the evidence is to trust the party that generated it, then the evidence has a structural ceiling.&lt;/p&gt;

&lt;p&gt;I have been building &lt;strong&gt;AURORA&lt;/strong&gt;, a system that seals AI decisions into signed, timestamped evidence records.&lt;/p&gt;

&lt;p&gt;This week I shipped the part that makes the word &lt;em&gt;independent&lt;/em&gt; more real:&lt;/p&gt;

&lt;p&gt;an offline verifier.&lt;/p&gt;

&lt;p&gt;A reviewer can now take an AURORA evidence bundle, drop the &lt;code&gt;.zip&lt;/code&gt; into a browser, and verify the bundle against the cryptographic material inside it.&lt;/p&gt;

&lt;p&gt;No OpenSSL.&lt;/p&gt;

&lt;p&gt;No shell.&lt;/p&gt;

&lt;p&gt;No account.&lt;/p&gt;

&lt;p&gt;No trust in me required.&lt;/p&gt;

&lt;p&gt;The verifier checks the manifest, file-hash inventory, RSA signature, record hash chain, and RFC 3161 timestamp evidence. Each result is shown with its own scope boundary.&lt;/p&gt;

&lt;p&gt;Here is what I learned building it.&lt;/p&gt;




&lt;h2&gt;
  
  
  1. The verifier has to be provider-neutral
&lt;/h2&gt;

&lt;p&gt;The first important design decision was not about UI.&lt;/p&gt;

&lt;p&gt;It was about what the verifier should actually verify.&lt;/p&gt;

&lt;p&gt;A naive verifier checks a vendor:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Is this a valid GlobalSign timestamp?&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;or:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Is this a valid FreeTSA token?&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;That sounds reasonable at first. But it couples your verification logic to a specific provider.&lt;/p&gt;

&lt;p&gt;The moment you change timestamp providers, add a second provider, or let enterprise customers bring their own TSA, your verifier becomes a provider adapter instead of an evidence verifier.&lt;/p&gt;

&lt;p&gt;That is not the shape I wanted.&lt;/p&gt;

&lt;p&gt;So the verifier checks the &lt;strong&gt;bundle contract&lt;/strong&gt;, not the vendor.&lt;/p&gt;

&lt;p&gt;It checks:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;the canonical record bytes hash to the recorded &lt;code&gt;data_hash&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;the RSA signature verifies over the canonical JSON with the bundled public key&lt;/li&gt;
&lt;li&gt;the public key fingerprint matches the published fingerprint&lt;/li&gt;
&lt;li&gt;the record hash chain is self-consistent where the required material exists&lt;/li&gt;
&lt;li&gt;the RFC 3161 timestamp token imprint binds to the timestamped message&lt;/li&gt;
&lt;li&gt;the bundle file inventory matches the recorded SHA-256 hashes&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;None of those checks require the verifier to care which TSA vendor issued the timestamp.&lt;/p&gt;

&lt;p&gt;A bundle timestamped by a free TSA and a bundle timestamped by a qualified TSA should travel through the same verification model.&lt;/p&gt;

&lt;p&gt;The trust layer can change.&lt;/p&gt;

&lt;p&gt;The verification contract should not.&lt;/p&gt;

&lt;p&gt;That separation matters.&lt;/p&gt;

&lt;p&gt;If the verifier is tied to a single provider, then the evidence format inherits the lifecycle of that provider relationship. If the verifier is tied to the bundle contract, the evidence format can outlive provider changes.&lt;/p&gt;

&lt;p&gt;That is the property I wanted first.&lt;/p&gt;




&lt;h2&gt;
  
  
  2. One core, two consumers, no hidden app dependency
&lt;/h2&gt;

&lt;p&gt;The verifier has two consumers.&lt;/p&gt;

&lt;p&gt;The first is a standalone CLI. That is for auditors, engineers, or reviewers who want to run verification outside AURORA entirely.&lt;/p&gt;

&lt;p&gt;The second is the backend &lt;code&gt;/verify/bundle&lt;/code&gt; endpoint. That is for the browser-based upload flow.&lt;/p&gt;

&lt;p&gt;The obvious move would be to put the verifier core in one shared package and import it everywhere.&lt;/p&gt;

&lt;p&gt;I did not do that.&lt;/p&gt;

&lt;p&gt;The standalone verifier needs to stay dependency-minimal. An external reviewer should not need to install a FastAPI app, database client, auth layer, storage SDK, or internal AURORA package just to check a signature.&lt;/p&gt;

&lt;p&gt;So the core is a pure library:&lt;/p&gt;

&lt;p&gt;bytes in.&lt;/p&gt;

&lt;p&gt;structured result out.&lt;/p&gt;

&lt;p&gt;No database.&lt;/p&gt;

&lt;p&gt;No service imports.&lt;/p&gt;

&lt;p&gt;No &lt;code&gt;app.crypto&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;No TSA client imports.&lt;/p&gt;

&lt;p&gt;No HTTP assumptions.&lt;/p&gt;

&lt;p&gt;The CLI reads a path, loads the bundle, calls the core, and renders text or JSON.&lt;/p&gt;

&lt;p&gt;The backend receives an uploaded &lt;code&gt;.zip&lt;/code&gt;, calls the same core, and returns the structured result.&lt;/p&gt;

&lt;p&gt;To keep the two copies aligned, the verifier core is vendored into the backend by a sync script. The script stamps a SHA-256 of the source copy, and CI can run a &lt;code&gt;--check&lt;/code&gt; mode to fail the build if the backend copy drifts from the standalone verifier.&lt;/p&gt;

&lt;p&gt;This feels redundant until you remember the product goal.&lt;/p&gt;

&lt;p&gt;The whole point is not to ask people to trust AURORA more than necessary.&lt;/p&gt;

&lt;p&gt;A verifier that only works inside AURORA’s app tree is not an independent verifier. It is just another internal endpoint.&lt;/p&gt;

&lt;p&gt;The standalone path has to remain real.&lt;/p&gt;




&lt;h2&gt;
  
  
  3. The bug that taught me the most: normalize, do not overfit
&lt;/h2&gt;

&lt;p&gt;The first regression caught a very small mistake with very large implications.&lt;/p&gt;

&lt;p&gt;I added a manifest check for the bundle format.&lt;/p&gt;

&lt;p&gt;The verifier had a whitelist of accepted format strings.&lt;/p&gt;

&lt;p&gt;My test fixture passed.&lt;/p&gt;

&lt;p&gt;A real production bundle failed.&lt;/p&gt;

&lt;p&gt;The reason was not cryptographic. It was not a broken signature. It was not a malformed bundle.&lt;/p&gt;

&lt;p&gt;It was casing.&lt;/p&gt;

&lt;p&gt;Production used:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;AURORA_Verification_Bundle
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The verifier expected a slightly different casing and separator convention.&lt;/p&gt;

&lt;p&gt;The bundle was valid.&lt;/p&gt;

&lt;p&gt;My equality check was too brittle.&lt;/p&gt;

&lt;p&gt;The fix was to normalize before comparing:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;trim&lt;/li&gt;
&lt;li&gt;lowercase&lt;/li&gt;
&lt;li&gt;normalize separators&lt;/li&gt;
&lt;li&gt;then compare against the canonical format identity&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The lesson is simple:&lt;/p&gt;

&lt;p&gt;when validating identifiers you control on both sides, normalize first.&lt;/p&gt;

&lt;p&gt;Exact string whitelists are useful at protocol boundaries. But when your own system already has historical or production variants, a strict literal check can turn valid evidence into a false failure.&lt;/p&gt;

&lt;p&gt;False negatives in a verifier are dangerous.&lt;/p&gt;

&lt;p&gt;Imagine an auditor uploads a legitimate evidence bundle and the tool says &lt;code&gt;FAILED&lt;/code&gt; because one internal label used a different underscore pattern.&lt;/p&gt;

&lt;p&gt;That is not integrity.&lt;/p&gt;

&lt;p&gt;That is your validator mistaking formatting for evidence.&lt;/p&gt;




&lt;h2&gt;
  
  
  4. Failure modes need to be honest
&lt;/h2&gt;

&lt;p&gt;This is the part I care about most.&lt;/p&gt;

&lt;p&gt;A verifier has power because it produces a verdict.&lt;/p&gt;

&lt;p&gt;That also makes it dangerous.&lt;/p&gt;

&lt;p&gt;If it hides ambiguity, rounds warnings into passes, or makes trust claims it has not actually proven, it becomes another system the reviewer has to trust blindly.&lt;/p&gt;

&lt;p&gt;That defeats the purpose.&lt;/p&gt;

&lt;p&gt;So the verifier separates required checks, warnings, unavailable checks, and non-applicable checks.&lt;/p&gt;

&lt;p&gt;For example, AURORA currently has a dual-path timestamp condition in some bundles.&lt;/p&gt;

&lt;p&gt;The timestamp hash bound at sealing time and the direct hash of the bundled timestamp token can differ because the bundled token may come from a separate TSA call.&lt;/p&gt;

&lt;p&gt;That looks suspicious if you only compare bytes mechanically.&lt;/p&gt;

&lt;p&gt;But it is not necessarily a tampering signal.&lt;/p&gt;

&lt;p&gt;So the verifier reports it as a warning, with a plain-language explanation. The integrity verdict can still pass if the required signature, hash, manifest, and record-chain checks pass.&lt;/p&gt;

&lt;p&gt;Same with timestamp trust status.&lt;/p&gt;

&lt;p&gt;If a TSA is non-qualified, the result says &lt;code&gt;non_qualified&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;If a qualified trust-list validation has not been performed, the verifier does not pretend it has.&lt;/p&gt;

&lt;p&gt;That boundary is part of the result.&lt;/p&gt;

&lt;p&gt;Not hidden.&lt;/p&gt;

&lt;p&gt;Not dressed up.&lt;/p&gt;

&lt;p&gt;Not silently promoted.&lt;/p&gt;

&lt;p&gt;The principle is:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;say exactly what was checked, exactly what passed, exactly what failed, and exactly what was not proven.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;That may sound obvious.&lt;/p&gt;

&lt;p&gt;It is not how a lot of software behaves.&lt;/p&gt;

&lt;p&gt;A verifier that hides its own limits is just another trust demand.&lt;/p&gt;




&lt;h2&gt;
  
  
  5. HTTP semantics matter more than they look
&lt;/h2&gt;

&lt;p&gt;The browser verifier exposed another small but important distinction:&lt;/p&gt;

&lt;p&gt;a malformed upload and a failed verification are not the same thing.&lt;/p&gt;

&lt;p&gt;If someone uploads a random file, or a &lt;code&gt;.zip&lt;/code&gt; without a manifest, the server could not perform bundle verification.&lt;/p&gt;

&lt;p&gt;That is a structural input problem.&lt;/p&gt;

&lt;p&gt;It should return something like:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;422 Unprocessable Entity
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;But if someone uploads a well-formed AURORA bundle and the RSA signature fails, that is different.&lt;/p&gt;

&lt;p&gt;The verifier successfully ran.&lt;/p&gt;

&lt;p&gt;The answer is just no.&lt;/p&gt;

&lt;p&gt;That should return:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;200 OK
{
  "ok": false,
  "result": "FAILED"
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A failed cryptographic check is not a server error.&lt;/p&gt;

&lt;p&gt;It is a valid verification result.&lt;/p&gt;

&lt;p&gt;Collapsing both cases into a generic &lt;code&gt;400&lt;/code&gt; loses the distinction between:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;I could not read this evidence.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;and:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;I read this evidence, and it does not verify.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;For auditors, that distinction matters.&lt;/p&gt;

&lt;p&gt;For debugging, it matters.&lt;/p&gt;

&lt;p&gt;For trust, it matters.&lt;/p&gt;




&lt;h2&gt;
  
  
  6. The browser is convenience. The CLI is independence.
&lt;/h2&gt;

&lt;p&gt;The new browser verifier is useful because it removes friction.&lt;/p&gt;

&lt;p&gt;A reviewer does not need OpenSSL installed. They do not need to know which command verifies a signature or how to parse an RFC 3161 token.&lt;/p&gt;

&lt;p&gt;They can upload the bundle and see the axes:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;manifest&lt;/li&gt;
&lt;li&gt;file inventory&lt;/li&gt;
&lt;li&gt;canonical record hash&lt;/li&gt;
&lt;li&gt;signature&lt;/li&gt;
&lt;li&gt;record hash chain&lt;/li&gt;
&lt;li&gt;timestamp evidence&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That is the accessible path.&lt;/p&gt;

&lt;p&gt;But it is not the only path.&lt;/p&gt;

&lt;p&gt;The CLI remains important because it is the fully independent path.&lt;/p&gt;

&lt;p&gt;The browser flow still asks AURORA’s server to run the verifier core. That is useful, but not maximally independent.&lt;/p&gt;

&lt;p&gt;The CLI lets a reviewer run the same verification logic outside AURORA’s infrastructure.&lt;/p&gt;

&lt;p&gt;That is why the verification guide now has both:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;browser-based bundle verification for no-CLI review&lt;/li&gt;
&lt;li&gt;OpenSSL / CLI verification for fully independent offline review&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The browser lowers the barrier.&lt;/p&gt;

&lt;p&gt;The CLI preserves the trust boundary.&lt;/p&gt;

&lt;p&gt;Both matter.&lt;/p&gt;




&lt;h2&gt;
  
  
  7. What the verifier does not do
&lt;/h2&gt;

&lt;p&gt;The verifier does not tell you the AI decision was correct.&lt;/p&gt;

&lt;p&gt;It does not tell you the decision was lawful.&lt;/p&gt;

&lt;p&gt;It does not tell you the decision was fair.&lt;/p&gt;

&lt;p&gt;It does not tell you a regulator approved it.&lt;/p&gt;

&lt;p&gt;It does not tell you a court will admit it.&lt;/p&gt;

&lt;p&gt;That is not its job.&lt;/p&gt;

&lt;p&gt;Its job is narrower:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;is this evidence package intact?&lt;/li&gt;
&lt;li&gt;do the hashes match?&lt;/li&gt;
&lt;li&gt;does the signature verify?&lt;/li&gt;
&lt;li&gt;does the timestamp evidence bind to the expected message?&lt;/li&gt;
&lt;li&gt;can an external reviewer reproduce the integrity checks?&lt;/li&gt;
&lt;li&gt;does the result state its own limits clearly?&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That narrowness is intentional.&lt;/p&gt;

&lt;p&gt;AURORA is not trying to be the judge.&lt;/p&gt;

&lt;p&gt;It is trying to be the custody layer.&lt;/p&gt;




&lt;h2&gt;
  
  
  Where it lands
&lt;/h2&gt;

&lt;p&gt;The result is now live:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;aurora-audit.com/verify-bundle
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;An external reviewer with no account, no CLI, and no reason to trust me can drag an AURORA evidence bundle into the browser and watch the verifier check the package.&lt;/p&gt;

&lt;p&gt;The core is still beta.&lt;/p&gt;

&lt;p&gt;The rough edges still show.&lt;/p&gt;

&lt;p&gt;Qualified timestamp trust-list validation is a separate trust layer and is still being hardened. The verifier does not pretend otherwise.&lt;/p&gt;

&lt;p&gt;But the important part is real now:&lt;/p&gt;

&lt;p&gt;the evidence can leave the original workflow and still be checked.&lt;/p&gt;

&lt;p&gt;That is the line I wanted to cross first.&lt;/p&gt;

&lt;p&gt;If you have built independently verifiable evidence systems, I would be interested in how you handled the trust-boundary question:&lt;/p&gt;

&lt;p&gt;where did you draw the line between “verify” and “trust me”?&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F19notxycn26s1o6pi6re.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F19notxycn26s1o6pi6re.png" alt=" " width="800" height="895"&gt;&lt;/a&gt;&lt;/p&gt;

</description>
      <category>security</category>
      <category>webdev</category>
      <category>compliance</category>
      <category>ai</category>
    </item>
    <item>
      <title>Building RFC 3161 Layer 2 Verification for AI Decision Evidence</title>
      <dc:creator>Lee HeeJun</dc:creator>
      <pubDate>Thu, 11 Jun 2026 16:06:04 +0000</pubDate>
      <link>https://dev.to/jakkrow/building-rfc-3161-layer-2-verification-for-ai-decision-evidence-3986</link>
      <guid>https://dev.to/jakkrow/building-rfc-3161-layer-2-verification-for-ai-decision-evidence-3986</guid>
      <description>&lt;p&gt;Most developers think timestamping means sending a request to a Time Stamping Authority and storing the response.&lt;/p&gt;

&lt;p&gt;Something like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;POST /tsa
Content-Type: application/timestamp-query

&amp;lt;binary timestamp request&amp;gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;But that is not verification.&lt;/p&gt;

&lt;p&gt;That is only asking for a timestamp.&lt;/p&gt;

&lt;p&gt;If you want to use a timestamp as evidence, you need to verify what came back.&lt;/p&gt;

&lt;p&gt;And in RFC 3161, that means dealing with CMS SignedData.&lt;/p&gt;

&lt;h2&gt;
  
  
  The problem
&lt;/h2&gt;

&lt;p&gt;An RFC 3161 timestamp response is not just a date.&lt;/p&gt;

&lt;p&gt;It is a cryptographic structure.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;If you want to treat the timestamp as evidence, you need to answer several questions:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Does the timestamp token refer to the payload I actually submitted?&lt;/li&gt;
&lt;li&gt;Does the message imprint match my payload digest?&lt;/li&gt;
&lt;li&gt;Which certificate signed the timestamp token?&lt;/li&gt;
&lt;li&gt;Is the CMS signature valid?&lt;/li&gt;
&lt;li&gt;Was the signer certificate valid for timestamping?&lt;/li&gt;
&lt;li&gt;Can this verification be repeated later by another party?&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Most basic integrations stop too early.&lt;/p&gt;

&lt;p&gt;They parse the response, extract the generated time, store the token, and assume that is enough.&lt;/p&gt;

&lt;p&gt;For a normal application timestamp, that may be acceptable.&lt;/p&gt;

&lt;p&gt;For an evidence system, it is not.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I am building
&lt;/h2&gt;

&lt;p&gt;I am building AURORA, a cryptographic evidence layer for AI decisions.&lt;/p&gt;

&lt;p&gt;The goal is not to decide whether an AI decision was fair, legal, or correct.&lt;/p&gt;

&lt;p&gt;The goal is narrower:&lt;/p&gt;

&lt;p&gt;preserve verifiable evidence of what was recorded.&lt;/p&gt;

&lt;p&gt;A sealed AI decision record can include:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;SHA-256 record hash&lt;/li&gt;
&lt;li&gt;RSA digital signature&lt;/li&gt;
&lt;li&gt;RFC 3161 timestamp token&lt;/li&gt;
&lt;li&gt;public verification URL&lt;/li&gt;
&lt;li&gt;downloadable evidence bundle&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The timestamp layer forced me to go deeper into RFC 3161 and CMS verification than I originally expected.&lt;/p&gt;

&lt;p&gt;The code snippets below are simplified for explanation, but the verification boundaries are the same.&lt;/p&gt;

&lt;h2&gt;
  
  
  Layer 1: Parsing the timestamp response
&lt;/h2&gt;

&lt;p&gt;The first layer is relatively straightforward.&lt;/p&gt;

&lt;p&gt;You parse the timestamp response, extract the timestamp token, and read the embedded TSTInfo structure.&lt;/p&gt;

&lt;p&gt;Conceptually:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;tsr&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;tsp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;TimeStampResp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;load&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;token_der&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="n"&gt;timestamp_token&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;tsr&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;time_stamp_token&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="n"&gt;signed_data&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;timestamp_token&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;content&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;

&lt;span class="n"&gt;tst_info&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;tsp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;TSTInfo&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;load&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;signed_data&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;encap_content_info&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;][&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;content&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="n"&gt;parsed&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;dump&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then you check the message imprint.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;message_imprint&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;tst_info&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;message_imprint&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="n"&gt;hashed_message&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;message_imprint&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;hashed_message&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="n"&gt;native&lt;/span&gt;

&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;hashed_message&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="n"&gt;expected_digest&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="nc"&gt;ValueError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Timestamp imprint does not match payload digest&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This confirms that the timestamp token refers to the digest you expected.&lt;/p&gt;

&lt;p&gt;That matters because the TSA does not timestamp your original JSON, PDF, or database record directly.&lt;/p&gt;

&lt;p&gt;It timestamps a digest.&lt;/p&gt;

&lt;p&gt;If the digest in TSTInfo does not match your payload digest, the token is not evidence for your record.&lt;/p&gt;

&lt;p&gt;But Layer 1 is still not enough.&lt;/p&gt;

&lt;p&gt;It proves that the token contains the expected imprint.&lt;/p&gt;

&lt;p&gt;It does not fully prove that the CMS signature is valid.&lt;/p&gt;

&lt;h2&gt;
  
  
  Layer 2: Verifying CMS SignedData
&lt;/h2&gt;

&lt;p&gt;The harder part is verifying the CMS SignedData structure itself.&lt;/p&gt;

&lt;p&gt;This is where the integration becomes less like an API call and more like applied cryptography.&lt;/p&gt;

&lt;p&gt;A timestamp token is a signed object.&lt;/p&gt;

&lt;p&gt;To verify it properly, you need to inspect the signer, the signed attributes, the encapsulated content, and the certificate usage.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. Do not assume &lt;code&gt;certs[0]&lt;/code&gt; is the signer
&lt;/h2&gt;

&lt;p&gt;One easy mistake is assuming that the first certificate inside the CMS certificate set is the signer certificate.&lt;/p&gt;

&lt;p&gt;That is unsafe.&lt;/p&gt;

&lt;p&gt;CMS does not guarantee that &lt;code&gt;certs[0]&lt;/code&gt; is the signer.&lt;/p&gt;

&lt;p&gt;The signer must be selected using the SignerInfo identifier.&lt;/p&gt;

&lt;p&gt;Conceptually:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;select_signer_certificate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;certs&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;signer_info&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;signer_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;signer_info&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;sid&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;

    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;cert&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;certs&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nf"&gt;cert_matches_signer_id&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;cert&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;signer_id&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;cert&lt;/span&gt;

    &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="nc"&gt;ValueError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Signer certificate not found&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The signer can be identified by issuer and serial number, or by subject key identifier.&lt;/p&gt;

&lt;p&gt;If you choose the wrong certificate, verification fails.&lt;/p&gt;

&lt;p&gt;Worse, you may build an implementation that appears to work only because one provider happens to order certificates in a convenient way.&lt;/p&gt;

&lt;p&gt;That is not a verification strategy.&lt;/p&gt;

&lt;p&gt;That is an accident.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Verify the &lt;code&gt;message-digest&lt;/code&gt; signed attribute
&lt;/h2&gt;

&lt;p&gt;CMS signed attributes usually include a &lt;code&gt;message-digest&lt;/code&gt; attribute.&lt;/p&gt;

&lt;p&gt;This digest must match the digest of the encapsulated TSTInfo content.&lt;/p&gt;

&lt;p&gt;Conceptually:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;expected_digest&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;extract_message_digest_attr&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;signed_attrs&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;actual_digest&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;sha256&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;tst_info_der&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;digest&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;expected_digest&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="n"&gt;actual_digest&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="nc"&gt;ValueError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;CMS message-digest does not match TSTInfo&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This step is easy to misunderstand.&lt;/p&gt;

&lt;p&gt;The signature is not simply over your original payload.&lt;/p&gt;

&lt;p&gt;In this structure, the signature is over the signed attributes.&lt;/p&gt;

&lt;p&gt;Those signed attributes include a digest of the encapsulated content.&lt;/p&gt;

&lt;p&gt;So the chain is:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;original payload
→ payload digest
→ TSTInfo message imprint
→ TSTInfo DER
→ CMS signedAttrs message-digest
→ CMS signature
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Each boundary matters.&lt;/p&gt;

&lt;p&gt;If one layer is skipped, the evidence chain becomes weaker.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. Handle the signed attributes encoding correctly
&lt;/h2&gt;

&lt;p&gt;This was one of the most annoying parts.&lt;/p&gt;

&lt;p&gt;CMS signedAttrs are encoded with an IMPLICIT context-specific tag on the wire.&lt;/p&gt;

&lt;p&gt;But for signature verification, the signed attributes need to be verified as a SET OF attributes.&lt;/p&gt;

&lt;p&gt;In simplified form:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;signed_attrs_for_verification&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sa"&gt;b&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="se"&gt;\x31&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;signed_attrs_wire&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;:]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That small encoding detail can break verification for hours.&lt;/p&gt;

&lt;p&gt;The data may look logically correct.&lt;/p&gt;

&lt;p&gt;The parsed object may look correct.&lt;/p&gt;

&lt;p&gt;The certificate may be correct.&lt;/p&gt;

&lt;p&gt;The digest may be correct.&lt;/p&gt;

&lt;p&gt;But if the exact bytes passed into signature verification do not match the expected DER encoding, the signature fails.&lt;/p&gt;

&lt;p&gt;This is one of the places where “I parsed the object” and “I verified the evidence” become very different things.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. Verify the CMS signature
&lt;/h2&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;Conceptually:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;public_key&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;verify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;signature_bytes&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;signed_attrs_for_verification&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;padding&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;PKCS1v15&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
    &lt;span class="n"&gt;hash_algorithm&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If this succeeds, the CMS signature over the signed attributes is valid.&lt;/p&gt;

&lt;p&gt;If it fails, the timestamp token should not be treated as verified evidence.&lt;/p&gt;

&lt;p&gt;At this stage, you are no longer merely trusting that the TSA returned something that looks valid.&lt;/p&gt;

&lt;p&gt;You are checking the cryptographic structure yourself.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. Check certificate usage
&lt;/h2&gt;

&lt;p&gt;A valid signature is still not the entire story.&lt;/p&gt;

&lt;p&gt;The certificate should also be valid for timestamping.&lt;/p&gt;

&lt;p&gt;For RFC 3161 timestamping, that usually means checking for the &lt;code&gt;id-kp-timeStamping&lt;/code&gt; extended key usage.&lt;/p&gt;

&lt;p&gt;Conceptually:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;eku&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;cert&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;extensions&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get_extension_for_class&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;x509&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ExtendedKeyUsage&lt;/span&gt;
&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="n"&gt;value&lt;/span&gt;

&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;ExtendedKeyUsageOID&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;TIME_STAMPING&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;eku&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="nc"&gt;ValueError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Certificate is not valid for timestamping&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Without this check, you may verify a signature from a certificate that was not intended to issue timestamp tokens.&lt;/p&gt;

&lt;p&gt;For an evidence layer, that distinction matters.&lt;/p&gt;

&lt;p&gt;The question is not only:&lt;/p&gt;

&lt;p&gt;“Was this signed?”&lt;/p&gt;

&lt;p&gt;The better question is:&lt;/p&gt;

&lt;p&gt;“Was this signed by the right kind of certificate, for the right kind of purpose, over the right content?”&lt;/p&gt;

&lt;h2&gt;
  
  
  Why this matters for AI decision evidence
&lt;/h2&gt;

&lt;p&gt;AURORA is built around AI decision records.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;For internal analytics, a normal database log may be enough.&lt;/p&gt;

&lt;p&gt;For audit evidence, it is weaker.&lt;/p&gt;

&lt;p&gt;A database timestamp says:&lt;/p&gt;

&lt;p&gt;“This is what the database currently says.”&lt;/p&gt;

&lt;p&gt;A cryptographic timestamp says:&lt;/p&gt;

&lt;p&gt;“This digest existed at this time, according to an external timestamp authority.”&lt;/p&gt;

&lt;p&gt;A verified cryptographic timestamp says:&lt;/p&gt;

&lt;p&gt;“We checked that the timestamp token, imprint, signature, signer certificate, and evidence chain are internally consistent.”&lt;/p&gt;

&lt;p&gt;That is a stronger evidence boundary.&lt;/p&gt;

&lt;h2&gt;
  
  
  What AURORA does not claim
&lt;/h2&gt;

&lt;p&gt;AURORA does not determine whether an AI decision was fair.&lt;/p&gt;

&lt;p&gt;It does not determine whether a decision was ethical.&lt;/p&gt;

&lt;p&gt;It does not replace legal review.&lt;/p&gt;

&lt;p&gt;It does not guarantee regulatory compliance.&lt;/p&gt;

&lt;p&gt;Those are separate questions.&lt;/p&gt;

&lt;p&gt;AURORA focuses on evidence preservation.&lt;/p&gt;

&lt;p&gt;It tries to answer a narrower but important question:&lt;/p&gt;

&lt;p&gt;“What exactly was recorded, and can that record be verified later?”&lt;/p&gt;

&lt;h2&gt;
  
  
  Key takeaways
&lt;/h2&gt;

&lt;p&gt;The main lessons from implementing this layer:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;A timestamp response is not the same as a verified timestamp.&lt;/li&gt;
&lt;li&gt;RFC 3161 timestamp tokens contain CMS SignedData.&lt;/li&gt;
&lt;li&gt;The message imprint must match the payload digest.&lt;/li&gt;
&lt;li&gt;The CMS &lt;code&gt;message-digest&lt;/code&gt; signed attribute must match TSTInfo.&lt;/li&gt;
&lt;li&gt;The signer certificate should be selected by SignerInfo, not by array position.&lt;/li&gt;
&lt;li&gt;signedAttrs encoding details matter.&lt;/li&gt;
&lt;li&gt;The signer certificate should be valid for timestamping.&lt;/li&gt;
&lt;li&gt;Evidence systems should expose verification metadata, not hide it.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  The result
&lt;/h2&gt;

&lt;p&gt;AURORA’s verification layer is designed to verify RFC 3161 timestamp evidence instead of blindly trusting that a TSA response is valid.&lt;/p&gt;

&lt;p&gt;That timestamp layer sits alongside:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;SHA-256 record hashing&lt;/li&gt;
&lt;li&gt;RSA signatures&lt;/li&gt;
&lt;li&gt;public verification pages&lt;/li&gt;
&lt;li&gt;downloadable evidence bundles&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The broader goal is simple:&lt;/p&gt;

&lt;p&gt;AI decision records should be verifiable later.&lt;/p&gt;

&lt;p&gt;Not just logged.&lt;/p&gt;

&lt;p&gt;Verified.&lt;/p&gt;

&lt;p&gt;Project: &lt;a href="https://aurora-audit.com" rel="noopener noreferrer"&gt;https://aurora-audit.com&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Sample PDF: &lt;a href="https://aurora-audit.com/sample-audit.pdf" rel="noopener noreferrer"&gt;https://aurora-audit.com/sample-audit.pdf&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Have you implemented RFC 3161, CMS SignedData, or timestamp verification before?&lt;/p&gt;

&lt;p&gt;I would be interested in hearing what edge cases you ran into.&lt;/p&gt;

</description>
      <category>security</category>
      <category>python</category>
      <category>cryptography</category>
      <category>ai</category>
    </item>
  </channel>
</rss>
