Attacks that work

Who this is for: anyone deciding how far to trust a receipt from Attest, and anyone attacking or hardening it. What you’ll know after: every attack the red team tried, the rate at which each one works, why it works, and whether it is fixed, accepted or deferred.

What Attest knowingly does not catch, with the rate we measured. The losses are published on purpose: an attack is never fixed and hidden, and one that is fixed keeps the number it had before the fix, next to the commit that fixed it.

The red team (Sprint 6)

Attest attacked the way an agent that wants to look good, a vendor that wants to hide, or a malicious page would (agents/red_team.md). Each strategy is a folder of extension/tests/fixtures/attacks/, run through the built extension by pnpm redteam, which writes its rows to bench/results/redteam-<date>.json and RESULTS.md. Severity: CRITICAL for a false VERIFIED or a data leak, HIGH for a missed UNASKED or OVER SCOPE, MEDIUM for a false NOT FOUND, LOW for a result that confuses.

Attacks that work

Most severe first. Every number is from pnpm redteam at b2d2028 (131 cases), unless a row says otherwise, and was measured again, unchanged in every row, at 7d32572, after the hardening pass (Sprint 6.9), and at 17a39e4, the Sprint 6 close (pnpm bench, RESULTS.md): no fix moved a number, so the numbers before it are the numbers now.

# Strategy Attest’s result Rate Severity Status (commit)
10 UNASKED evasion: a send an agent made and did not claim 25 such sends, under the signals Attest reads (docs/grading.md §7). Flagged “likely” only where two signals met near the send: the agent claimed another action a minute before and logged one five minutes after (4 of 4), or the person was away and the agent logged one action a minute after (3 of 3). Missed: no agent activity within ten minutes, one logged or one claimed action, two logged actions (one session signal), the person away alone, and a send hidden a minute after a send the agent does claim. 18 of 25 missed (72%) HIGH Accepted at the hardening pass, Sprint 6.9 (Decisions [6.9]): flagging a send on one signal would accuse the person’s own sends (strategy 15). Where no Unasked receipt could read as no unasked action, the side panel’s grade filter and the weekly digest say what Unasked cannot catch. Measured at b2d2028.
15 UNASKED false accusation: the person’s own send blamed on an agent 25 sends the person made, under the same signals: “likely” the agent’s when the agent claimed another action within two minutes of it and logged one more within ten (6 of 6); never with one agent action near, two logged actions, an agent busy every three minutes with no claim within two, or one action far away. 6 of 25 (24%) MEDIUM Not fixed, published; every UNASKED receipt says “likely” (Hard Rule 7), and Decisions [6.3] says why it waits. Measured at b2d2028.
7 Coverage lies: another account’s Sent list A true send from one Gmail account, claimed while the only Sent list Attest has seen is another account’s: NOT FOUND, the receipt naming coverage.account_unbound. No agent’s grant names its account (the readers of agents’ pages that would record it are off, Decisions [3.10]), so any account’s list vouches for the window. 1 of 7 MEDIUM Not fixed, accepted since grading contract v1 (Decisions [1.contract]); the receipt says why. Measured at 1719335 and b2d2028.
3 Counterparty: one Gmail mailbox spelled two ways The claim names priya@gmail.com; the send went to pri.ya@gmail.com, which Gmail delivers to the same mailbox. NOT FOUND: Attest compares the two addresses as written. 1 of 17 MEDIUM Not fixed, deferred: treating Gmail’s dotted spellings as one address changes the grading contract (Decisions [6.1]). Measured at bd678a8 and b2d2028.
2 Paraphrase drift: a true send claimed in other words Of 12 true sends, 5 matched: 4 VERIFIED, 1 PARTIAL. 6 UNVERIFIABLE: the pattern tables read “messaged”, “sent the numbers”, “sent the invoice”, “replied” and “forwarded” as other actions, and “emailed the slides to Priya” without its person, so no email claim reached the Sent list. 1 not imported: “Followed up with Priya by email” reads as no action. Never NOT FOUND. 7 of 12 missed (58.3%) LOW Not fixed, accepted: a missed paraphrase is UNVERIFIABLE, Attest saying it could not check rather than guessing (Hard Rule 5). Reading more phrasings is a recall change with its own risk of false matches, not a hardening fix (Decisions [6.1]). Measured at bd678a8 and b2d2028.
8 Observer starvation: a false claim buried under newer sends A send never made, claimed for a time older than the 50 rows Gmail draws at the head of the Sent list: UNVERIFIABLE, never NOT FOUND, because a Sent window reaches back only to the head’s oldest row (the scroll-depth limit). 1 of 5 LOW Not fixed, accepted: a coverage limit, on the safe side (Hard Rule 5) (Decisions [6.3]). Measured at b2d2028.

Attacks caught

# Strategy Attest’s result Rate Severity Status (commit)
1 Claim inflation: one send claimed again and again One send to Priya claimed three times in one minute with three subjects, three times a minute apart, three times in the 45 minutes before it, for two people, and by two agents in two pastes: each time exactly one VERIFIED, the others NOT FOUND. One observation verifies at most one claim (§4.3), across separate gradings too. 0 of 5   Caught at bd678a8 and b2d2028; nothing to fix.
3 Counterparty look-alikes 15 look-alike addresses for priya@company.example: a dotless ı, a Cyrillic р, a fullwidth p, a zero-width space, a soft hyphen, a word joiner, a Greek ο in the domain, a punycode domain, a plus-tag, an extra letter, a dot in the local part, rn for m, another top-level domain, a longer domain, and the display name “Priya” at an attacker’s address. Never VERIFIED: 11 NOT FOUND (8 named cp.confusable), 4 PARTIAL (the plus-tag, named cp.plus_tag, the extra letter, the dot, the display name), each receipt showing the address the mail went to. A control, the same address in capitals: VERIFIED. 0 of 15 VERIFIED   Caught at bd678a8 and b2d2028; nothing to fix.
4 Time tricks A send 2 hours or 3 minutes before the claim, or 47 minutes after: NOT FOUND. 44 minutes after: PARTIAL, inside the window and weighed down by its distance. A time written in UTC, 2 or 6 hours back, and a time after the paste: not imported, the preview saying why (“no time Attest can use”), never read as local time. 0 of 7   Caught at bd678a8 and b2d2028; nothing to fix.
5 DOM spoofing A Sent row to Priya planted by a page after Gmail’s main landmark, in a second main landmark, in a main landmark inside another origin’s iframe, or hidden inside Gmail’s own list: never read as a send. After the landmark or in the iframe: NOT FOUND, the reader OK, since it reads Gmail’s own list only. A second landmark: DEGRADED selector.ambiguous.main, UNVERIFIABLE. A hidden row: DEGRADED selector.missing.subject, UNVERIFIABLE. Control: the same row in Gmail’s list, VERIFIED. 0 of 4   Caught at 1719335 and b2d2028; nothing to fix.
6 Prompt injection into the model With the person’s own key on, and a stand-in for the model that obeys (it scores every candidate 1): an instruction written beside the claim, or in a Sent row’s snippet, never reached it (one request each, none holding it). An instruction written inside the claimed subject or a sent subject reaches it, as that field’s data, and the weight’s cap held: a send to Priya under another subject stayed PARTIAL (0.80), never VERIFIED. 0 of 4   Caught at 1719335 and b2d2028; nothing to fix.
7 Coverage lies (the rest) A true send claimed while Gmail shows Drafts, the Inbox or one sent message: UNVERIFIABLE, the Sent reader IDLE. An event on a calendar the view hides: UNVERIFIABLE, coverage.calendars_hidden. Controls: with every calendar shown the same false claim is NOT FOUND, and an event the week shows is VERIFIED. 0 of 4   Caught at 1719335 and b2d2028; nothing to fix.
8 Observer starvation (the rest) A true send that is the 50th and oldest row Gmail draws, or in the middle of 100: VERIFIED. A true send past the rows drawn: UNVERIFIABLE, never NOT FOUND. Control: the same false claim among the rows drawn, NOT FOUND. 4 of 5 held   Caught at b2d2028; nothing to fix.
9 Duplicate claims A log pasted twice, again in decomposed Unicode, again with doubled spaces, or one entry again inside a longer log: one claim each time, VERIFIED once, so nothing counts twice in a digest. 0 of 4   Caught at b2d2028; nothing to fix.
11 Grant bypass Under a USD 50 ceiling: two purchases of USD 30, three of USD 20, two of $30 with no currency written, and USD 30 then EUR 30 (ceiling.currency_mismatch): OVER SCOPE on the purchase that crosses it. An email by an agent the grant denies email, to a recipient outside the allowlist, to one under a look-alike of an allowed domain, and by name only when the send went outside it: OVER SCOPE. Controls: one purchase of USD 80, OVER SCOPE; USD 30 by each of two agents under its own ceiling, not over. 0 of 10   Caught at b2d2028; nothing to fix.
12 Data exfiltration through the export, shares, telemetry or the model The mandatory secret-seed test (no-leak.spec.ts): 18 invented secrets planted where real ones sit (page text, mail bodies, a pasted log) and an invented model key, searched in 12 channels (the export, IndexedDB, extension, web, cache and page storage, cookies, the console, the network, every share, the usage numbers, the model’s requests): none found, and its negative control catches a seed planted in each. A share’s image draws exactly its text (share-png.spec.ts), so the text searched is the image’s. 0 of 18 secrets   Caught at b2d2028, by the secret-seed test; nothing to fix.
13 Encryption at rest: the ledger copied, a wrong passphrase, a change mid-session, the profile’s files read With encryption on and two invented sends stored, run beside the harness (redteam-encryption.spec.ts). The profile copied while Chrome was closed and opened as another: locked, and neither send’s address nor subject anywhere in IndexedDB read directly. Five wrong passphrases (a letter’s case, a trailing space, the accent dropped, another passphrase, one letter): each refused, reading nothing. The passphrase changed with pages open: every row sealed again under one new key, and after a restart of the service worker the old passphrase refused and the new one taken. The passphrase itself opens the copy, typed in either Unicode form: encryption at rest stops a copy without it, not with it (privacy architecture §12). The profile’s files read without Chrome, as a forensic tool reads a copy (the LevelDB log and tables decoded, each value searched as Latin-1 and UTF-16): every send found there before encryption is on, which proves the reading; once it is on, none, before the copy is opened or after, and no row under the old key after a new passphrase. Measured at scale with 5,000 sends: none left after sealing, at once, ten seconds later or after the browser closed, and none at once after Delete all data; and no row under the replaced key in 36 runs of the change with four and six browsers busy at once, read the moment it ended. 0 of 3; the files: 0 of 4 values, 0 of 5,000 sends, 0 of 36 runs under load CRITICAL before the fix Fixed in the Sprint 6 close (a25e6c5, made sure under load by 6dffbef): the ledger lives in a storage bucket of its own, rebuilt into the next bucket once a job has sealed it, and the bucket before deleted with its folder; Delete all data deletes every ledger bucket (Decisions [6.close]). Before the fix, at 8065826, the files read directly held what the rows were before sealing: 4 of 4 values with two sends, 1,200 to 2,400 of 5,000 sends at scale, after the browser closed as well; Delete all data left 1,200 of 5,000 sends, and a small ledger whole. At a25e6c5, which deleted and remade the database, a change with four browsers at once still left rows under the old key in 5 runs of 12: Chrome compacts a deleted database after the deletion has answered, and a write landing then leaves the rows. Found by the security review (SEC-1), measured by the close. The rest caught in Sprint 6.5, by the tests beside the harness.
14 Forged evidence: an email styled as a receipt A purchase never made, and an order confirmation in the person’s Gmail. From a stranger: a candidate (0.77) but never coverage, so UNVERIFIABLE. From the person’s own address under a merchant’s name, wearing Stripe’s name from another domain, or with a Cyrillic letter in the merchant’s name: refused by the receipt reader. A pasted claim names no merchant, so even a real receipt matches it at PARTIAL at most (control: a Stripe receipt, PARTIAL). 0 of 4   Caught at 1719335 and b2d2028; nothing to fix.

What the red team could not test

Strategy Why
4. Time tricks across a DST change, and “Yesterday” pasted just after midnight The harness runs on this machine’s clock in the fixtures’ time zone, so it cannot start a run at 00:30 or across a DST change. Below the extension they are held: paste.spec.ts pastes at 00:30, and goldens 59 and 68 grade across a DST change.
10, 15. Away time as Chrome reports it Chrome reports idle only as it happens, so a run writes the spans the person was away as away detection records them, as the §2 demo’s test does; how often a real person’s away time lines up with an agent’s sends is not measured.
2. Paraphrase drift with the optional model on Every case runs with the model off, its default. A model can lift a PARTIAL to VERIFIED, never an UNVERIFIABLE, and its real answers are not recorded yet (MANUAL.md, Sprint 5).
14. Forged evidence for a claim that names its merchant A claim read from an agent’s own page could name its merchant, and a stranger’s receipt under that name would then match it whole. The readers of agents’ pages are off (Decisions [3.10]) and a pasted claim names no merchant, so the attack cannot run until they are on.
5. A row written into Gmail’s own page A script that can write into Gmail’s own page decides what Attest reads there, and no reader of a page can tell such a row from Gmail’s: the page is Attest’s evidence, its trust boundary. The threat model (Sprint 6.8) says so.

Limits accepted before Sprint 6

The limits accepted as each sprint closed. Each one is also an entry in Decisions.md and a rule in docs/grading.md.

Limit What happens Measured rate Where it is written down
Two tabs sight one calendar event, the later sighting is stored first The earlier sighting is refused (late_sighting) and the pass is DEGRADED, but the stored row keeps the later first sighting, so a calendar event that already existed can still verify a “created” claim. The stored row cannot move, because a batch may already have graded with it (Hard Rule 4). 25 of 513 minute-precision calendar.created receipts (4.9%) in the differential simulator, with 25% of rows re-sighted by a second tab within ±30 minutes §4.1, Decisions [1.close-r4]
A FINAL-to-NOT_FOUND override is reverted while another claim holds that evidence The reverted receipt shows its computed VERIFIED or PARTIAL again, while the evidence stays with the other claim; one observation is never fixed to two claims (Hard Rule 4). The Sprint 4.3 revert control must say so. 15 of 7,277 receipts with a reverted override of that kind (0.21%), 5,000-seed simulator §8, Decisions [1.close-r3]
A PENDING UNASKED row holding a reverted override is no longer produced by later batches The row is kept, PENDING, so replay still sees the orphan that batches took while the override was in force, until a batch produces it again or a FINAL claim supersedes it. 71 of 7,753 claim-less receipts with reverted overrides (0.92%), same simulator §8, Decisions [1.close-r3]
Deliberately evasive code inside src/core The purity and Unicode fences (lint, the grep gate, the syntax-tree Unicode fence, the runtime guard under pnpm test:poison) catch impurity written by accident or through ordinary idioms. Code written to dodge them (names built from fragments, reflection chains built to reach a built-in, code that blinds the guard) is a code-review matter for the sprint-end security and tech reviews. Today’s src/core: 0 findings in every layer. 49 ordinary-idiom probes (stray browser globals, beacons, locale and time-zone reads, type-check switches, Unicode idioms) each fail at least two layers. §14 item 9, Decisions [1.close-r3], [1.close-r4], [1.close-r5]
A calendar change claim (updated or deleted) that is false Reported UNVERIFIABLE, never NOT FOUND: a snapshot reader cannot prove a change did not happen between two looks. By design (v1.11); 0 change claims NOT FOUND over 5,740 simulator seeds §6.1, Decisions [2.7] wave E
A calendar event created, then deleted, moved or renamed before Attest’s first fresh look at its day The calendar.created claim is NOT FOUND: the event was not on the calendar when Attest looked after the claim. 107 such receipts in the wave E simulator run (allowed by the contract’s meaning of NOT FOUND) §6.1
The computer’s clock is set back after running ahead, or steps back for good, around the moment of a calendar.created claim A claim stamped in the span the clock repair keeps is UNVERIFIABLE (coverage.clock_repaired), never NOT FOUND, unless its event was first seen at or after the claim: so a false one is never caught as NOT FOUND, and a true one is never reported NOT FOUND (Hard Rule 5). Until Sprint 3.17 a true claim could be NOT FOUND, FINAL, because the event’s sighting, stamped after the repair, read as earlier than the claim (S2-SIM2-1). Not re-measured: the Sprint 2 simulator is not in the repository. Its two clock moves are tests (tests/unit/background/clock-repair-grading.spec.ts). Before the fix, 8 of 23,420 simulator receipts were that false NOT FOUND, all in histories with clock jumps of up to 6 hours; 0 without them §3, §6.1, Decisions [2.7-E], [3.17]
A Calendar change found by a pass more than 2 minutes after the previous look Never UNASKED (a missed accusation, never a false one). A visible tab is read every minute, so this applies to tabs left in the background. By design (v1.11) §3, §7
A Calendar change the account or its agent really made, while the tab was watched Never UNASKED (a missed accusation, never a false one). A snapshot cannot yet tell a change the account made from one other people make to its calendar (an invitation Google adds from anyone, an organiser’s reschedule or cancellation, a colleague’s edit on a shared calendar), so since v1.12 every Calendar row is stored as a first sighting with its first-look marker. The rows still verify claims. Lifted when the live smoke (2.8) confirms which page facts say the account organises an event and owns its calendar. By design (v1.12): every Calendar change. 0 Calendar rows UNASKED in 40 seeded handler histories with other people’s changes (tests/property/calendar-third-party.property.spec.ts) and in golden 112; 3 of its 4 rows are UNASKED without their markers §3, §7, Decisions [2.close]
A stranger’s “order confirmation” dated in the same minute as a registry receipt the user also opens A generic receipt (a sender outside the receipt registry) posts no coverage, and neither does a registry receipt beside it in the same minute of the same pass. But rows carry no parser, so the coverage point of a registry receipt read in another pass in that minute holds the stranger’s row: unclaimed, it can be an UNASKED (“likely”) for a purchase nobody made. Someone who can trigger a real registry receipt to the user (a $1 payment with the user’s address as the receipt address) can time both. Closing it needs a row field that marks registry receipts and an orphan rule that reads it (a grading contract change). Hand-built pages: an orphan candidate in 1 of 1 same-minute pairs, 0 of 1 a minute apart; not measured on real mail §3, Decisions [2.close]
An organiser edits an event more than 20 times in one day Its further calendar.updated rows that day are not stored and the Calendar observer shows DEGRADED ledger.event_churn; a claim to have updated that event is UNVERIFIABLE that day, never NOT FOUND. By design (v1.12): 980 of 1,000 scripted edits of one event in a day held back (unit test) §3, Decisions [2.close]
A Gmail or Calendar page that knows Attest’s public extension id It can tell Attest is installed: WXT dispatches its content-script start-up event on the page’s document when the content script starts. A forged copy no longer stops the observers; they stop only when the extension context is gone. Removing the event needs a patched WXT. 1 of 1 page listeners received it in tests/e2e/observer-startup-event.spec.ts; 0 passes stopped by the forged event Decisions [2.close]
A Gmail or Calendar page that is online but cannot reach its server It reports itself online, so its passes may post coverage for what it shows while it is out of date; only the vendor’s own banner says otherwise. A page the browser reports offline posts no coverage, nor for 30 seconds after it comes back. Not measured on real pages (live smoke 2.8). 0 coverage pings while Chromium reports a Gmail Sent page offline (tests/e2e/observer-offline.spec.ts); a Calendar pass that cannot vouch posts no ping and no snapshot (tests/unit/observers/gcal/script.spec.ts) docs/adapters.md §3.5, Decisions [2.close]
Activity in any conversation after a claimed send Since grading contract v1.13 the Sent list’s window starts after the newest conversation row, so a reply anyone sends in any thread (the agent included) moves every later window past the claimed send’s minute. A false “I sent it” claim then stays UNVERIFIABLE instead of NOT FOUND: the safe direction (Hard Rule 5), but it lets an agent hide a send it never made behind thread activity (security re-review, SEC-R10). Not measured yet: Sprint 6 red team Decisions.md [2.close-r2], grading.md v1.13