<?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: transparent-pegasus</title>
    <description>The latest articles on DEV Community by transparent-pegasus (@transparent-pegasus).</description>
    <link>https://dev.to/transparent-pegasus</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%2F4054768%2Fe1ab8c2a-aeab-4c35-9762-6b8177803b47.png</url>
      <title>DEV Community: transparent-pegasus</title>
      <link>https://dev.to/transparent-pegasus</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/transparent-pegasus"/>
    <language>en</language>
    <item>
      <title>Closing every exit but one: how QR Crypt's decisions converge on T21</title>
      <dc:creator>transparent-pegasus</dc:creator>
      <pubDate>Thu, 30 Jul 2026 10:28:49 +0000</pubDate>
      <link>https://dev.to/transparent-pegasus/closing-every-exit-but-one-how-qr-crypts-decisions-converge-on-t21-371l</link>
      <guid>https://dev.to/transparent-pegasus/closing-every-exit-but-one-how-qr-crypts-decisions-converge-on-t21-371l</guid>
      <description>&lt;p&gt;QR Crypt encrypts text on a device you disconnect during setup and never reconnect. Ciphertext leaves as a QR code on the screen, and a second device carries it to an ordinary messenger.&lt;/p&gt;

&lt;p&gt;The threat model has 22 rows. One of them, T21, has a countermeasure column that says the hop has effectively none. I want to trace how the project got there on purpose, because T21 sits at the end of a chain of decisions that each closed a different way for the application to hand a secret to a third party. Reading the repository in that order makes the architecture legible in a way the feature list does not.&lt;/p&gt;




&lt;h2&gt;
  
  
  The guarantee has a precondition, and the precondition sets the work
&lt;/h2&gt;

&lt;p&gt;Section 2 of &lt;code&gt;docs/security/threat-model.md&lt;/code&gt; scopes what the app promises. The application does not intentionally transmit plaintext, private keys, or symmetric keys off the device. Then comes the clause that governs everything after it: &lt;strong&gt;this guarantee assumes that the installed code is authentic.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;That clause splits the design problem in two.&lt;/p&gt;

&lt;p&gt;For the shipped app, the job is subtractive. Find every route by which the code as built could move a secret outward, and close it. Each closure is a decision with a date and a consequence.&lt;/p&gt;

&lt;p&gt;For an app that is not the shipped app, no closure inside the code helps. Counterfeit code cooperates with none of it.&lt;/p&gt;

&lt;p&gt;Do the first job completely and the second half becomes the whole remaining risk. That is T21, and the project reaches it by working through the first job one exit at a time.&lt;/p&gt;

&lt;h2&gt;
  
  
  Exit 1: the network
&lt;/h2&gt;

&lt;p&gt;The app makes no external API calls. &lt;code&gt;connect-src 'self'&lt;/code&gt; arrives as a response header from &lt;code&gt;public/_headers&lt;/code&gt;, and production builds also inject the supported directives as a meta CSP fallback. &lt;code&gt;frame-ancestors&lt;/code&gt; stays header-only because browsers ignore it in meta. No analytics, no logging, no error-reporting SDK, no external fonts, no CDN.&lt;/p&gt;

&lt;p&gt;Three same-origin request kinds survive: fetching the PWA and its static assets, &lt;code&gt;GET /reachability-sentinel.txt&lt;/code&gt; as the destructive probe that gates wipe-on-online, and a recurring &lt;code&gt;HEAD /manifest.webmanifest?reach=…&lt;/code&gt; display probe. None of them carries user data or relay payload.&lt;/p&gt;

&lt;p&gt;Two refinements in the verification checklist show where the obvious version of this rule leaks.&lt;/p&gt;

&lt;p&gt;The first: an allowlist by method and path is insufficient. An allowed static GET can carry a payload in its query string. So the e2e assertions bind every allowed request to a fixed set of query keys, and any request carrying a key beyond the two named probes fails.&lt;/p&gt;

&lt;p&gt;The second is stated in the docs as a warning against a plausible regression. Same-origin is not a safety property by itself, because code that POSTed relay text to this same origin would still be same-origin. The gate checks what a request carries, not only where it goes.&lt;/p&gt;

&lt;p&gt;The camera decoder got the same treatment. &lt;code&gt;zxing-wasm&lt;/code&gt; ships its reader binary as a hashed same-origin asset that Workbox precaches, and a module-scope &lt;code&gt;locateFile&lt;/code&gt; overrides the library's default CDN fetch. No CDN, no runtime network request.&lt;/p&gt;

&lt;h2&gt;
  
  
  Exit 2: storage and diagnostics
&lt;/h2&gt;

&lt;p&gt;An app that writes a secret somewhere durable has moved it outward in a slower way, so the storage rule is absolute about one class of value: no frame-derived or OCM1-derived value reaches app-managed IndexedDB, localStorage, or CacheStorage.&lt;/p&gt;

&lt;p&gt;localStorage runs on an allowlist of four keys: &lt;code&gt;oc-theme&lt;/code&gt;, &lt;code&gt;oc-lang&lt;/code&gt;, &lt;code&gt;oc-offline-ack-pending&lt;/code&gt;, &lt;code&gt;oc-online-tab&lt;/code&gt;. The values are constrained too. The acknowledgement marker holds the literal &lt;code&gt;"1"&lt;/code&gt;. The tab key holds &lt;code&gt;top&lt;/code&gt; or &lt;code&gt;relay&lt;/code&gt; and nothing else.&lt;/p&gt;

&lt;p&gt;A maintainer requirement on 2026-07-24 removed in-app QR storage. There is no saved-QR feature and no &lt;code&gt;qrArtifacts&lt;/code&gt; store. Ciphertext and key QR codes get on-screen display, an explicit PNG or ZIP export, and the clipboard. Plaintext persistence is forbidden in both localStorage and IndexedDB.&lt;/p&gt;

&lt;p&gt;Then the quieter exits. Production builds never write plaintext, keys, or QR contents to the console. Errors use fixed i18n and &lt;code&gt;AppError&lt;/code&gt; mappings and never interpolate raw input, frame metadata, &lt;code&gt;transferId&lt;/code&gt;, hashes, or &lt;code&gt;caught.message&lt;/code&gt;. An error string that echoed its input would be an egress path with a friendly name.&lt;/p&gt;

&lt;p&gt;The verification checklist enumerates the sinks it searches after capture, copy, paste, playback, rejection, close, &lt;code&gt;pagehide&lt;/code&gt;, and timeout. Request URLs including query names and values. Request header names and values. Request bodies. Every IndexedDB database's schema names, meaning database, object-store and index names and key paths, alongside its keys and values. CacheStorage metadata and bodies. localStorage. Console. &lt;code&gt;window.onerror&lt;/code&gt; and unhandled rejections. Visible error text. &lt;code&gt;document.title&lt;/code&gt;. &lt;code&gt;location.href&lt;/code&gt;. History state.&lt;/p&gt;

&lt;p&gt;One line in that checklist reads like scar tissue. A single marker set has to cover every sink, because a request oracle that searches fewer markers than the storage oracle is the hole the line exists to close. The storage oracle's self-test plants two markers, one as a typed-array value and one that appears only in an object-store name, and requires exactly those two matches.&lt;/p&gt;

&lt;h2&gt;
  
  
  Exit 3: the online surface
&lt;/h2&gt;

&lt;p&gt;Closing egress inside the app does nothing if the app runs its sensitive features on a connected device, so a decision on 2026-07-22 removed that possibility. OnlineGate fail-closes encryption, decryption, key management, and settings while online. Online use covers installation and the clean-origin optical relay, and nothing else.&lt;/p&gt;

&lt;p&gt;The interesting part is what happens to a device that still holds secrets. A device carrying keys, post-quantum identity rows, or a Vault key takes the block-and-wipe path instead of the relay path. The relay feature therefore creates no incentive to bring a key-holding device online, which closes the social hole rather than the technical one.&lt;/p&gt;

&lt;p&gt;Relay eligibility requires a completed sensitive-store scan proving absence, and it fails closed when the scan is indeterminate. The offline-to-online transition fires TransientClear, which drops plaintext, decryption results, and result payloads at once.&lt;/p&gt;

&lt;p&gt;The docs disclose what this does not establish. A clean-origin check is a current logical-absence result under a completed scan, so it proves neither historical non-use nor physical erasure. A second tab writing a key during a relay session is a documented race against a stale eligibility signal.&lt;/p&gt;

&lt;h2&gt;
  
  
  Exit 4: the update path
&lt;/h2&gt;

&lt;p&gt;On 2026-07-22 a commit reads &lt;code&gt;feat: remove SW update features&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;An update channel needs the origin to stay reachable. Keeping one would mean every installed device retains a live endpoint it is designed to contact, which is an exit by definition. Deleting the channel costs real usability, and &lt;code&gt;deviations.md&lt;/code&gt; records the cost: a new version means sanitizing the device and installing fresh.&lt;/p&gt;

&lt;h2&gt;
  
  
  Exit 5: narrowing what can run at all
&lt;/h2&gt;

&lt;p&gt;Code paths that exist can be reached. Several decisions cut the reachable surface rather than defending it.&lt;/p&gt;

&lt;p&gt;On 2026-07-23 the active policy narrowed to maximum: ML-KEM-1024 and ML-DSA-87 only. The wire contract still carries four suites, but the balanced family (768/65) exists as reserved types and suite codes, and the operational boundary rejects it as &lt;code&gt;UNSUPPORTED_ALGORITHM&lt;/code&gt; before any cryptographic processing. The project declines to implement it even for interoperability testing.&lt;/p&gt;

&lt;p&gt;RSA went further. Key creation and decryption are gone, with no decrypt-only compatibility retained, and schema validation rejects non-&lt;code&gt;A256GCM&lt;/code&gt; OCM1 messages. The &lt;code&gt;OCP1&lt;/code&gt; and &lt;code&gt;RSA-OAEP-3072&lt;/code&gt; vocabulary still parses so old records load, and it cannot be operated. The docs state the price without hedging: a non-extractable RSA private key cannot be rescued, so ciphertext of that kind is unrecoverable.&lt;/p&gt;

&lt;p&gt;Then the configuration hole. &lt;code&gt;VITE_ENABLE_RSA&lt;/code&gt; is retired and rejected, and its presence in the environment now fails validation at application startup rather than being normalized away. The note that follows is the kind of thing most projects leave out. The rejection is a startup check and not a build check, so a production build carrying a stale value still succeeds and embeds it, and that build then fails for every user instead of failing in CI. They wrote down which end of the pipeline catches it.&lt;/p&gt;

&lt;p&gt;Allocation got bounded too, under T20. The v2 receiver accepts at most 128 frames of 1,000 bytes, so 128,000 artifact bytes. A bare-payload paste caps at 170,672 characters with a second 128,000-byte check after decoding. Canonical CBOR separately caps one map at 8 entries, the decoded value at 13 total entries, keys at 18 UTF-8 bytes, and text at 300 UTF-8 bytes, because a byte cap alone bounds neither entry count nor retained heap. Decoding uses null-prototype maps and keeps no process-lifetime cache of attacker-supplied keys.&lt;/p&gt;

&lt;h2&gt;
  
  
  When closing a hole would open a bigger one
&lt;/h2&gt;

&lt;p&gt;F-02, found on 2026-07-28, is the decision I would show anyone arguing that security work means adding controls.&lt;/p&gt;

&lt;p&gt;The finding was real. A recipient got no signal that the same ciphertext had already been accepted, and nothing refused a reused authenticated message ID carrying different ciphertext.&lt;/p&gt;

&lt;p&gt;The fix shipped as a session-scoped receipt cache. One module-local map lives in each loaded window's JavaScript realm, bounded, evicting oldest first. A matching ciphertext hash raises a flag behind an explicit reveal, and a reused message ID gets refused as &lt;code&gt;MESSAGE_ID_REUSED&lt;/code&gt;. Reload clears it, and other tabs never see it.&lt;/p&gt;

&lt;p&gt;Cross-session detection would work better, and the project refused to build it. Persisting a receipt means writing a frame-derived or OCM1-derived value, which breaks the invariant from Exit 2, contradicts the clean-origin boot gate's premise, and would fail the verification gate on purpose. Closing the replay hole by that route would open a storage exit.&lt;/p&gt;

&lt;p&gt;So the stronger control stays unbuilt, filed as an open security-design decision, with the work it would require spelled out: device-keyed opaque tags instead of a public ciphertext hash, ownership moved inside the boot controller, and matching spec edits. Conversation IDs, monotonic sequence numbers, hash chains, and adding a message ID to the AES v1 format sit in the same pile.&lt;/p&gt;

&lt;h2&gt;
  
  
  The relay: hardened, and declared untrusted
&lt;/h2&gt;

&lt;p&gt;The online relay is the one place where the design deliberately lets attacker-influenced bytes pass through a connected device, so T19 documents exactly how far the hardening reaches.&lt;/p&gt;

&lt;p&gt;The hop enforces a two-kind allowlist: canonical &lt;code&gt;OCF2:&lt;/code&gt; strings whose untrusted outer header declares &lt;code&gt;pq-message&lt;/code&gt;, or one canonical &lt;code&gt;OCM1:&lt;/code&gt; message, never both in one transfer. The OCM1 path decodes the whole envelope and requires a byte-for-byte canonical re-encode to reproduce the input. Top-level &lt;code&gt;OCK1:&lt;/code&gt; and &lt;code&gt;OCP1:&lt;/code&gt; prefixes and disallowed OCF2 outer types get rejected.&lt;/p&gt;

&lt;p&gt;Then the limits, stated by the project rather than discovered by a reader. That equality check establishes structural canonicality and nothing else. The hop performs no completed-artifact hash check, no AEAD or signature verification, and no decryption. Accepted OCF2 chunk bytes carry whatever the sender put in them, and so does every sender-controlled OCM1 field: &lt;code&gt;keyId&lt;/code&gt;, &lt;code&gt;createdAt&lt;/code&gt;, &lt;code&gt;iv&lt;/code&gt;, &lt;code&gt;ciphertext&lt;/code&gt;, &lt;code&gt;aad&lt;/code&gt;. A public artifact relabelled as a message passes the outer filter and gets rejected only after the offline device assembles it. Relaying OCM1 also exposes the whole v1 envelope from one QR, including a 22-character &lt;code&gt;keyId&lt;/code&gt; that stays stable per symmetric key and works as a linkability handle across every message under it.&lt;/p&gt;

&lt;p&gt;Authentication happens at the offline endpoint or it does not happen.&lt;/p&gt;

&lt;h2&gt;
  
  
  What is left is T21
&lt;/h2&gt;

&lt;p&gt;Now put the pieces together from the attacker's side.&lt;/p&gt;

&lt;p&gt;The app makes no request that carries a payload. It writes no frame-derived value to storage. It interpolates nothing into an error string. It runs no sensitive feature while online, and a key-holding device that goes online gets wiped rather than relayed. It has no update channel to contact. Its unreachable algorithm paths are unreachable, and its configuration cannot re-enable them.&lt;/p&gt;

&lt;p&gt;Every one of those closures assumes the code doing the work is the code that was built. Remove that assumption and T21 is what you get.&lt;/p&gt;

&lt;p&gt;A counterfeit app on the offline device embeds keys or plaintext inside something shaped like ciphertext, displays it as &lt;code&gt;OCF2:&lt;/code&gt; frames or one &lt;code&gt;OCM1:&lt;/code&gt; message, and relies on you and your own online device to carry it out. The relay cannot authenticate opaque bytes, so it accepts them. The countermeasure column says effectively none, and points at T19 for the checks a compromised sender still satisfies.&lt;/p&gt;

&lt;p&gt;The capacity is estimated rather than waved at: up to 128,000 bytes across an OCF2 frame set, or 2,953 characters in a single OCM1. &lt;code&gt;frameCount&lt;/code&gt; and &lt;code&gt;totalByteLength&lt;/code&gt; are visible in every outer frame, and the payload bytes are opaque, so nobody watching the transfer can separate real ciphertext from exfiltration.&lt;/p&gt;

&lt;p&gt;T21 is the complement of the guarantee's precondition. Every other exit the application provides has an owner and a closure. This one requires the application to have been replaced, which is why no closure inside the application reaches it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Which makes the install route the entire guarantee
&lt;/h2&gt;

&lt;p&gt;T21 ends by naming its own dependency. Any claim that plaintext never reaches a third party rests entirely on installation integrity, and that is why the install route decision determines the overall guarantee.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Route B&lt;/strong&gt;: open the app URL, install the PWA, disconnect. The receiving user can perform no integrity check. Anyone holding the origin, its TLS, or its CDN serves one targeted device a modified bundle, the Service Worker keeps it, and the device retains that origin as a reachable target.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Route A&lt;/strong&gt;, the default for high assurance: download a Cosign-signed ZIP, verify signature and checksum, validate the archive container before extraction, rebuild from source and compare independently in a way that accounts for every archive member, then serve from &lt;code&gt;http://127.0.0.1&lt;/code&gt; and install from there. Stop the local server and no origin remains to reach.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;nation-state-security&lt;/code&gt; note states the position plainly: route A is the intended path, and the project accepts that route B can be tampered with.&lt;/p&gt;

&lt;p&gt;F-01, from the same 2026-07-28 review, is the finding that made this route usable. The README carried a partial Route A procedure while the archive's &lt;code&gt;INSTALL.txt&lt;/code&gt; was the only self-contained copy that actually reaches the offline device, which made the mandatory independent rebuild-and-compare easy to understate. The complete procedure now lives in its own document, and both READMEs keep a summary and a link.&lt;/p&gt;

&lt;p&gt;Release evidence grew to match: an archive-internal &lt;code&gt;SHA256SUMS.files&lt;/code&gt; covering every other ZIP member, fixed clean-checkout rebuild instructions, and a CI gate that compares two production builds' sorted file sets and per-file hashes in one runner.&lt;/p&gt;

&lt;p&gt;And then the limit on all of it, in the project's own words rather than a reviewer's. The CI gate establishes same-environment determinism only, so environment-independent reproducibility stays unverified. Cosign attests which workflow built from which commit, not that the source produced the binary. &lt;strong&gt;An attacker controlling the CI environment can publish a correctly signed backdoor&lt;/strong&gt;, and only an independent rebuild comparison detects it. Which is why the rebuild-and-compare step in Route A is marked mandatory rather than recommended.&lt;/p&gt;

&lt;p&gt;Three origin rules finish the boundary. A browser origin comes from scheme, host, and port alone, and this local deployment has neither TLS nor host authentication, so &lt;code&gt;localhost&lt;/code&gt; and &lt;code&gt;127.0.0.1&lt;/code&gt; count as different origins. Serve anything else on that host and port later and it gets same-origin access to the stored keys and the Vault key, where a non-extractable key still decrypts through &lt;code&gt;crypto.subtle&lt;/code&gt;. Change the port after installation and the app becomes a different origin that cannot reach its own data.&lt;/p&gt;

&lt;h2&gt;
  
  
  What "only" means here, and what it does not
&lt;/h2&gt;

&lt;p&gt;The convergence above covers one class of risk: paths where the application itself moves a secret to a third party. In that class, T21 is what remains after every other exit was closed by a dated decision.&lt;/p&gt;

&lt;p&gt;Three other classes sit outside it, and conflating them would overstate the result.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Declared non-goals.&lt;/strong&gt; Compromise of the OS, browser, or firmware. Keyloggers, screen recording, screenshots. Malware capturing camera frames. Supply-chain compromise at first install or reinstall. Physical theft. A user sharing a secret QR by mistake. Key loss through browser data deletion. These sit outside by declaration, because the attacker owns something the app cannot reach.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Residual confidentiality risk that is not T21.&lt;/strong&gt; Optical duplication at the moment a key QR is displayed (T3). The app's inability to establish that the user compared a fingerprint against the intended person rather than an attacker-supplied channel (T6). Malicious code that runs first on the same origin using the Vault key, a known Web Crypto boundary (T13). Superseded post-quantum generations staying decryptable until explicitly discarded (T14). A valid signature meaning only that it verified against a stored public key (T15).&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Detection coverage and availability.&lt;/strong&gt; Replay detection resetting on reload and losing entries to eviction (T2, F-02). Assembly no longer catching a single changed chunk that still decodes as canonical CBOR of the declared type (T16). The allocation ceilings under T20, which the docs classify as an availability and local-memory residual rather than a confidentiality one.&lt;/p&gt;

&lt;p&gt;Two more limits belong here. Wipe performs best-effort logical deletion, and physical erasure is not claimed, so retirement means NIST SP 800-88 or destroying the medium. And no independent third-party audit has happened: &lt;code&gt;security-review.md&lt;/code&gt; calls that an external blocker, records that release-approved has not been reached, and says outright that self-authored documents close nothing.&lt;/p&gt;

&lt;p&gt;Read with those boundaries in place, the claim is narrower than "only one risk" and more useful. Every exit the application could provide has a named owner, a dated decision, and a test that fails if someone reopens it. The one that survives requires the application to be something other than what was built, and the project routes its entire assurance argument through the install procedure for exactly that reason.&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;Repository&lt;/strong&gt;: &lt;a href="https://github.com/transparent-pegasus/qr-crypt" rel="noopener noreferrer"&gt;https://github.com/transparent-pegasus/qr-crypt&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;For the primary sources: &lt;code&gt;docs/security/threat-model.md&lt;/code&gt; §2 for the guarantee and its precondition, rows T19 through T21 for the relay and the residual, &lt;code&gt;docs/develop/deviations.md&lt;/code&gt; for the dated decisions, and &lt;code&gt;docs/develop/install-route-a/README.md&lt;/code&gt; for the procedure the argument depends on.&lt;/p&gt;

</description>
      <category>architecture</category>
      <category>security</category>
      <category>software</category>
    </item>
  </channel>
</rss>
