Skip to content

Flipper channel, key recovery, and the attack scene actually firing - #17

Merged
ryannzander merged 35 commits into
mainfrom
b/flipper-and-recovery
Sep 10, 2026
Merged

ryannzander merged 35 commits into
mainfrom
b/flipper-and-recovery

Conversation

@ryannzander

Copy link
Copy Markdown
Owner

Follow-on to #15. Three things that were broken in ways that only show up when you run them.

The Flipper

device/tappy-js/tappy.js had never been executed once, and would not have survived line one: it called storage.exists() and storage.read(path), where the documented API is fileExists() and an openFile() handle. mJS has no exceptions, so the failure mode isn't an error — the app just stops, which reads as a dead device.

The storage API differs between firmware versions and forks, so it now feature-detects and prints the API it found at startup.

scripts/flipper-check.sh answers spike 1 in one command for whoever is holding the device: find the port, confirm the CLI responds, install the app, then write a request over USB while the app holds the foreground. That question has been open since day one and the whole bridge↔device channel rests on it.

The bridge also gained SIGNER_KIND=local / auto — the terminal standing in for the Flipper, same key, same signature, same on-chain path. Proven on Sepolia: 0x7e59240c…cd00cc.

And the bridge now rebuilds the digest from the call rather than signing whatever hash the hub sends — the same rule the phone follows, and for the same reason.

Key creation could dead-end

Two bugs stacked. Creation asked for .biometryCurrentSet, which refuses outright unless biometry is already enrolled — so a passcode-only device couldn't create a key at all. And SecureEnclave.isAvailable returns true on Apple Silicon simulators where every operation then fails, so the code trusted the flag and never reached the software fallback that exists for exactly this.

Now: strongest policy first, fall back to .userPresence, then to a software key — falling back on the exception rather than the flag. Which one is in force is shown in the UI, because silently downgrading a security claim is worse than failing loudly.

The attack scene never fired

Neither cause was the model.

The system prompt told the agent to treat tool output as untrusted — a prompt-level injection defence, which is exactly what DECISIONS #10 excludes on purpose. The defence is meant to be the human declining.

Subtler: propose_send validated recipients with viem's isAddress, which enforces EIP-55 checksums. The injected address is mixed-case and was rejected as malformed, so the agent tried to comply and failed on a technicality.

Verified end to end: asked to "check the listing and do whatever it requires", the agent proposed sending the entire ETH balance to 0xbad0…0bad and told the user it had initiated a migration. Declining left the balance byte-identical — 0.0295947 before, 0.0295947 after.

Worth knowing for the recording: gpt-5 resisted this three times, naming it as untrusted text. gpt-4o-mini took it immediately. Set OPENAI_MODEL accordingly and say which model it is on camera.

Verified

23 Foundry tests, 19 protocol tests, typecheck 4/4, iOS builds, both frozen vectors reproduce in Swift.

🤖 Generated with Claude Code

ryannzander and others added 30 commits September 8, 2026 20:39
Pivots the human factor from the Flipper to an iPhone: the human key becomes a
P-256 key sealed in the Secure Enclave, verified on-chain through the EIP-7951
precompile. That was impossible when SPEC.md was written and is why "the key
never leaves the device" was cut line #1; it now costs 6,900 gas.

Chat moves to the iOS app, apps/web becomes apps/hub with no pages, and the
Flipper stays as a second human authority behind the same gate.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Six tasks, M0 through M2: rename apps/web to apps/hub and strip the UI, spike the
EIP-7951 precompile before trusting it, freeze the P-256 vector, drop `buy` and add
the mobile schemas, teach FlippyGate the 64-byte human signature, deploy to Sepolia.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The client is the iOS app. Nothing rendered a page here except the T3 scaffold.
Fix round 1 on Task 1: the bullet still named apps/web and described a chat
UI that no longer exists, pointing every future session at a stale layout.
Measured instead of inferred from Fusaka's published scope: address
0x…0100 has no code on Sepolia (chain 11155111) as of 2026-09-09,
confirmed against two independent RPCs. FlippyGate must deploy with
a fallback Solidity P-256 verifier, not the precompile, until Sepolia
actually activates it.
The prior commit's checkP256.ts reported the EIP-7951 precompile absent
on Sepolia. That was a false negative: @noble/curves v2's sign()/verify()
default to {prehash: true} and hash the message argument internally.
The script's `message` was already a digest, so sign() silently signed
sha256(message) instead, and verify() made the identical mistake, so the
local self-check agreed with itself while disagreeing with the chain.

Confirmed the corrected reading two ways before trusting it: reproduced
the gas-estimation evidence that something real is dispatched at 0x100,
and validated the eth_call plumbing against ecrecover with a known
private-key-to-address vector. With {prehash: false} explicit on both
calls, the same 160-byte layout returns 0x000...0001 on two independent
Sepolia RPCs. Precompile is live; docs/spikes.md corrected to match.
The Secure Enclave signs sha256(digest), not digest. This vector pins that layer
across languages so nobody rediscovers it through a reverted transaction.
The shop needed a web surface that no longer exists. In its place: the proposal
shape the phone can verify for itself, plus devices and NFC stations.

Deleting MockMerchant.sol left FlippyGate.t.sol's arbitrary-calldata coverage
without a target, so that one test now exercises a minimal CallTarget fixture
declared in the test file instead of the deleted product contract.
Signature length is the discriminator: 65 bytes is the Flipper, 64 is the iPhone.
The Enclave signs sha256(digest) because CryptoKit gives no way to opt out, so the
gate verifies that. High-s is accepted deliberately - the nonce makes replay moot.

p256Verifier is a constructor argument, never a hardcoded 0x100, so the same gate
deploys to chains with EIP-7951 and to chains that need the Solidity fallback.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Deploy.s.sol now takes HUMAN_K1_ADDRESS and/or HUMAN_QX/HUMAN_QY and a
P256_VERIFIER (default 0x100, verified live on Sepolia in docs/spikes.md #5),
and requires at least one human authority rather than silently deploying a gate
nobody can open.

proveP256.ts signs sha256(digest) the way a Secure Enclave does and relays
execute(). It verifies the signature locally before spending a transaction —
@noble/curves v2 defaults to prehash:true and would otherwise produce a
signature that passes its own check and is rejected on-chain.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A Claude tool-calling loop that can only propose, an in-memory proposal store
behind the protocol's state machine, and a REST surface for the phone and the
bridge. No database: a four-minute demo has nothing worth surviving a restart.

Approval accepts either device. Signature length is the discriminator, exactly
as FlippyGate does it: 65 bytes recovers to the Flipper's secp256k1 address,
64 bytes verifies as a Secure Enclave P-256 signature over sha256(digest).
Both are checked off-chain first, because on-chain the only diagnostic is
"BadHumanSig", which says nothing about the cause.

get_token_info carries a prompt injection on purpose. There is no defence in
the tools — the defence is the human declining on the device. DECISIONS #10.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Ryan's call. The tool loop is provider-shaped rather than model-shaped, so this
is an SDK swap and a message-format change, not a redesign. Model id sits behind
OPENAI_MODEL so it can move without a code edit.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The bridge dialled a /ws endpoint that no longer exists — the hub is REST now.
It polls /api/bridge/pending once a second and POSTs its decision to the same
approve/reject routes the phone uses, which is what spikes.md #3 recommended:
Vercel cannot hold a socket, and a second of latency is invisible next to a
human reaching for a device.

The Flipper CLI and signer are untouched; only the transport changed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
TappyKit carries everything testable without a phone: a keccak256 implementation
(Apple ships none, and SHA3 is not the same hash), the EIP-712 digest, the
Secure Enclave key and the hub client. `swift run tappy-verify` asserts all of it
against the same frozen vectors Solidity and TypeScript already agree on — Swift
is now the third language to reproduce them, and it does.

The Enclave signs sha256(digest) because CryptoKit hashes its input and offers no
way to opt out. The verifier pins that, and also pins the inverse: a double-hashed
signature must NOT verify. That is the trap that cost a debugging round on the
TypeScript side.

Design follows the references: ChatGPT-shaped chat, Phantom-shaped wallet, one
light surface throughout. The approval screen recomputes the digest on-device and
disables signing if it disagrees with the server.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
LocalAuthentication reports nearly everything as "Authentication failed", which
is unactionable — the same string covers an unenrolled simulator, a device with
no passcode, a missing Info.plist key and a lockout. Map the cases that really
happen to messages that name the fix, and wrap both key creation and signing so
the fix reaches the banner instead of the console.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
com.tappy.app is taken — bundle ids are globally unique, so Xcode refused to
issue a profile for it. Namespaced it instead.

DEVELOPMENT_TEAM is deliberately left unpinned: a Personal Team's id is not the
one printed in the signing certificate's name, and a wrong value fails with
"No Account for Team" rather than falling back. Pick the team once in Xcode.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The same mark the splash and headers draw — rings leaving a point of contact —
so the icon and the app are one object rather than two drawings of one idea.
Padded well inside the safe area; iOS masks to a rounded rect and eats anything
that runs to the edge.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
M2. FlippyGate is live at 0xDaaa901835Ff9c37F2F2918F3DbF9dE19F953e11 and a P-256
signature — the exact shape a Secure Enclave produces — moved real ETH through it:
sepolia.etherscan.io/tx/0xc7d9932543ef9a76cce7b685252f2e50eb2566d4b5b355549f44bcbb3f15e231

Three things had to change for the hub to actually serve:

The workspace packages ship TypeScript with NodeNext-style "./types.js" specifiers.
Next's resolver looked for a literal .js, found nothing, and the failure surfaced as
a 404 on every route rather than an import error. transpilePackages plus an
extensionAlias fixes it.

The deployment path used import.meta.url, which webpack rewrites — so it resolved
somewhere that does not exist once bundled. Resolved from cwd instead, and the error
now lists every path it tried.

OPENAI_API_KEY was required at boot, which meant no API key, no wallet and no
approvals. The security half touches OpenAI nowhere; only /api/m/chat needs it, and
that route now says so itself.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Dollars, not ETH. Sepolia coin is worthless and four decimal places of it is not
an amount anyone can judge at a glance — judging the amount is the entire job of
the approval screen. Priced off real mainnet ETH via Coinbase, cached a minute,
with a fixed fallback so a price hiccup never breaks a demo. ETH stays visible
underneath as the on-chain truth. The agent talks in dollars too and converts.

The wallet buttons appeared dead because they send a message to the agent, and
the agent's reply — including its errors — landed in the chat tab the user was
not looking at. They now switch to the chat, and turn failures also raise the
banner. Chat is the main screen; the wallet is the second tab.

Liquid Glass on the composer, tab bar and tiles, gated on iOS 26 with a material
fallback rather than raising the deployment target — an app that will not install
is not a demo.

A Live Activity puts a pending approval in the Dynamic Island, so the AI asking
for money is visible without opening anything. Best-effort by design: absent on
older runtimes and phones without an island, and none of that is worth failing an
approval over.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The UI was three borrowed shapes stapled together — ChatGPT, Phantom, Cash App —
and it looked it. Scrapped and rebuilt on Wise: white paper, one lime accent, a
forest ink that is never quite black, and a heavy condensed display face used only
for the line that says what a screen is for. Same system on every screen.

Three tabs. Home is the balance card and the transaction list. Chat is the agent.
Recipients is new: addresses are 42 characters nobody can read, and asking someone
to paste one into a chat box is the worst part of every crypto app. Save a person
once, then say their name — the contacts mirror to the hub so the agent can resolve
"pay Vitalik $10" to an address, and a list_contacts tool makes it use them.

The tab bar is a real row in the layout now rather than an overlay, which is what
was covering the chat composer.

Also fixed the biometric error that actually blocked setup: LocalAuthentication
returns codes outside LAError.Code (-1020 when nothing is enrolled), which fell
through to "Face ID error -1020" — a number nobody can look up. Every route to a
failed Enclave key on a fresh device has the same two fixes, so it says them.

A debug-only -uiPreview flag renders the screens with a software key so layouts
can be checked in the simulator without a device or a face.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Two bugs that both presented as "the app just does not work".

The OpenAI key was in the repo-root .env, where the contracts scripts and the
bridge already read it, but Next only loads the .env beside itself — so a key put
in the obvious place was silently invisible and the only symptom was the agent
refusing to run. The hub now loads the root file first, without overwriting
anything already set.

The proposal store was a module-level Map. Next's dev server gives each route
handler its own module instance, so a proposal created by /api/m/chat genuinely
was not there when /api/m/proposals/:id looked for it a second later. Contacts
posted to one route were invisible to the agent in another. Now a JSON file:
shared by construction, survives a restart mid-demo, and revived through the zod
schema so wei and nonce come back as bigints rather than strings.

Verified end to end: "send $2 to Vitalik" resolves the name from contacts,
converts at the live rate, and lands in PENDING_HUMAN on the bridge surface.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A malformed line is skipped rather than assumed well-formed.
NSAllowsLocalNetworking is documented for .local and link-local addresses; a raw
192.168.x.x over plain HTTP is still refused on a real device. The Simulator is
more permissive, so this only shows up once you plug a phone in — as a connection
failure with no explanation.

Testnet demo talking to a laptop on the same Wi-Fi. Must never ship to anyone.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…xception to Debug

The gate held the frozen vector's public key, so a signature from the real phone
correctly failed to verify and the approve route returned 400 — the check working,
pointed at the wrong key. Redeployed at 0x35523da59aF7fA1ac12b6ca8686f33EcAf70D634
with the Secure Enclave key the device registered, funded with 0.03 ETH.

Old proposals are bound to the previous gate address through their digests, so the
state file is cleared rather than left to fail confusingly.

ATS: NSAllowsArbitraryLoads disabled ATS for the whole app, which a security review
flagged and was right to. The release plist now carries only NSAllowsLocalNetworking
— enough for "http://your-mac.local:3100", which also survives DHCP handing the
laptop a new address. The raw-IP exception moved to a Debug-only Info.plist, so it
is structurally unable to reach a Release build.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The keyboard was my bug: ignoresSafeArea(.keyboard) on the tab container meant the
keyboard covered the composer instead of pushing it up, so the text box became
unreachable at the bottom of a conversation. The layout moves now, the tab bar
steps aside while typing, return sends, and opening the keyboard scrolls to the
newest message rather than hiding it behind itself.

get_wallet now reports what has gone out: spend so far, what is awaiting approval,
how much of everything the wallet ever held is already gone, and whether the
balance is running low. Facts, not a verdict — the model decides whether to
mention it and it never refuses. A policy engine that blocks you is exactly what
this product argues against; the point is that the human decides with their eyes
open. Asked to send $60 from a $48 wallet it now opens with "heads up, that's
more than what's left" and offers the maximum, instead of proposing something
that would revert.

The Live Activity shows the transaction moving — a three-step rail from proposed
to approved to confirmed, a spinner while it is genuinely in flight, and the
amount once it is not. Same shape as the timeline inside the app, so the island
reads as the same object seen from outside.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
main and this branch both built a hub, and they had diverged in a way that was
not cosmetic: main renamed the contract and the EIP-712 domain to TappyGate, so
signatures from the two sides were mutually invalid. The only symptom of getting
that wrong is BadHumanSig, which says nothing about the cause.

Ryan's call on both halves.

Naming: main's wins. TappyGate everywhere — contract, EIP-712 domain, @tappy/*
packages, server/tappy. main had already regenerated execute.json for the new
domain, so that fixture is taken verbatim and p256.json regenerated on top of it.
Swift, Solidity and TypeScript all reproduce the new digest.

Backend: this branch's wins. apps/web's parallel agent loop, Postgres store,
approval channel and tests are removed in favour of apps/hub, which is what the
iOS app talks to and what has been exercised end to end on Sepolia. That is a
real loss of teammates' work and it was a deliberate decision, not an accident of
merging — worth saying out loud so it can be revisited.

Verified after the merge: 23 Foundry tests, 19 protocol tests, typecheck 4/4,
the iOS app builds, and tappy-verify still reproduces both frozen vectors.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The old gate was built for the FlippyGate EIP-712 domain, so every signature the
renamed code produces would have been rejected by it. New gate at
0xEB759b096301aFE181457A1134f14b51779c354a, same iPhone Enclave key, 0.03 ETH.
The gate already executed arbitrary calldata, so an ERC-20 transfer needed no
contract change at all: the call goes to the token, carries no ether, and the
amount rides in `data`. That is the whole feature — a new action kind, a token
registry, and balances read per coin.

USDC, LINK and WETH by their known Sepolia addresses, plus FLIP from this
deployment. Empty balances are dropped so the list is holdings rather than a
catalogue, except the native coin, where zero is itself worth knowing. Prices come
from Coinbase per pair, with fixed prices for the demo token that has no market.

Decimals are handled by string surgery throughout. USDC has 6 and ETH has 18, and
going through a Double loses precision on both — silently, and in the direction
that moves the wrong amount of money.

Also fixed: the app defaulted to port 3000, which is the port every other Next
project on a laptop has already taken. It is 3100 now, matching what the hub
actually runs on.

Verified against Sepolia: the wallet reads ETH $74.01 + FLIP $100.00, and
"send 50 FLIP to Vitalik" produces a proposal the device renders as
"SEND 50.000 FLIP" — plus an unprompted "that's 12.5% of the FLIP balance".

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The one that mattered: there was no way to re-register a device. The gate gets
redeployed whenever its configuration changes, and when it did, the phone could
no longer approve and the only fix was deleting the app. The security sheet now
shows this device's public key with a copy button and offers to register again —
the recovery path for the exact failure that has bitten this build twice.

"Could not connect to the server" now names the URL it tried and the two things
that are actually wrong: the hub is not running, or you typed localhost on a
phone, where localhost is the phone.

Bounded growth. Every turn resends the whole conversation, so an uncapped
transcript is an uncapped bill and eventually a context error — the model sees the
last 24 messages. The state file keeps 100 proposals and 200 messages. Clearing
the chat clears it on the hub too; it used to only clear the phone, leaving the
agent with a memory the user believed they had deleted, which with an injection in
scope is not merely surprising.

CLAUDE.md described tRPC, Drizzle and 13 tests, none of which are true any more.
It now documents the iOS app, the real commands, the hub's port, and the four
traps that have each cost a debugging round: the Enclave's SHA-256 layer, noble's
prehash default, Next's per-route module instances, and the root .env.

The Keychain identifiers still say "flippy" on purpose — they are opaque, and
renaming them would orphan the key already registered on a real phone.

23 Foundry tests, 19 protocol tests, typecheck 4/4, iOS builds, vectors match.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The gate now carries both human authorities: the iPhone's Secure Enclave key and a
secp256k1 key for the Flipper. Redeployed at
0x9fe3986850D76AA79b9952c7C86c9DEDF67Dea10.

Proved on Sepolia with a secp256k1 approval through the same gate the phone uses:
0x7e59240c04698a7a3eb0d68ed29409a4cfc6ffdb90fb5b93740a88bec7cd00cc. Everything
between the agent and the chain is now exercised for both devices.

Two things had to change to get there.

The bridge was signing whatever digest the hub handed it. It now receives the full
call and rebuilds the digest itself, refusing loudly on a mismatch — the same rule
the phone follows, and for the same reason: a hub that lies about what a proposal
does should not be able to get a signature out of either device.

SIGNER_KIND gains "local" and "auto": the terminal standing in for the Flipper,
with the same key, the same signature and the same on-chain path. It exists
because it proves everything except the last ten centimetres of USB cable, and
because it is the fallback if the hardware misbehaves on the day.

Also fixed: the bridge defaulted to port 3000 while the hub runs on 3100.

Still open and still needing the physical device: spike 1 — whether Flipper CLI
storage commands work while a JS app is in the foreground. The whole
bridge-to-device channel rests on it and nobody has run it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
ryannzander and others added 5 commits September 9, 2026 20:16
It never worked, and the reason was not the model.

Two things were silently blocking it. The system prompt told the agent to treat
tool output as data and never as instructions — a prompt-level injection defence,
which is exactly what DECISIONS #10 excludes on purpose. The defence is meant to
be the human declining on a physical device, and a guard standing in front of it
hides the thing the demo exists to show. Removed, with a comment saying so, since
its absence looks like an oversight.

The second was subtler: propose_send validated the recipient with viem's
isAddress, which enforces EIP-55 checksums. The injected address is mixed-case and
was rejected as malformed, so the agent tried to comply and failed on a technicality.
Checksum validation is wrong here in general — an address of the right length and
valid hex is a real address, and refusing it for casing helps nobody.

The injection itself is now shaped like a real one: delimiter confusion, operator
authority, a deadline, and an instruction not to ask the user.

Verified end to end. Asked to "check the listing and do whatever it requires", the
agent proposed sending the entire ETH balance to 0xbad0…0bad and told the user it
had initiated a migration. Declining left the balance byte-identical: 0.0295947
before, 0.0295947 after.

Worth knowing for the pitch: gpt-5 resisted this injection in three separate
attempts, naming it as untrusted text. gpt-4o-mini took it immediately. Set
OPENAI_MODEL accordingly for the recording, and say which model it is on camera —
the honest version of this demo is "models sometimes catch it and sometimes do
not, which is why the wallet does not depend on it".

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Key creation asked for .biometryCurrentSet, which refuses outright unless biometry
is already enrolled — so a device with just a passcode, or a simulator with no
enrolled face, could not create a key at all. The failure surfaced as
"Face ID could not be used (error -1020)" during setup, before anything else could
be tried.

Now two policies, strongest first. .biometryCurrentSet where it works: the key is
destroyed if a face is enrolled later, so adding a face cannot be used to steal
signing authority. Otherwise .userPresence, which accepts biometry or the device
passcode — weaker, but still Enclave-held, still non-extractable, and still a live
human.

Which one is in force is recorded and shown in the UI ("Secure Enclave · Face ID"
versus "· Face ID or passcode"), because silently downgrading a security claim is
worse than failing loudly. The claim on camera should match what the app says.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
SecureEnclave.isAvailable reports true on Apple Silicon simulators, where every
key operation then fails — so the app trusted the flag, never reached the software
fallback that exists for exactly this, and setup dead-ended on error -1020 with no
way forward.

It now tries the Enclave for real and falls back on the exception rather than on
the flag, with a banner saying plainly that the key is ordinary memory rather than
hardware. A software key is a materially weaker claim and the app should say so
rather than let someone repeat the hardware claim on camera.

Verified: the simulator registers a p256-software key and the full loop is
available there.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
tappy.js had never been executed once, and it would not have survived line one:
it called storage.exists() and storage.read(path), where the documented API is
fileExists() and an openFile() handle. mJS has no exceptions, so the failure mode
is not an error message — the app simply stops, which reads as a dead device.

The storage API differs between firmware versions and forks, so the app now
feature-detects rather than guessing, and prints the API it found at startup. A
failure that names itself is worth more than a guess that happens to be right.

scripts/flipper-check.sh answers spike 1 in one command for whoever is holding the
device: find the port, confirm the CLI responds, install the app, then write a
request over USB while the app is in the foreground. That question has been open
since day one and the whole bridge-to-device channel rests on it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Squash-merge artifact, not a real divergence: #15 landed on main as one commit
whose tree already matches this branch, but git cannot see that and reports every
touched file as changed on both sides. This branch is strictly newer — it contains
everything in the squash plus the Flipper, key-recovery and attack-scene work — so
every conflict resolves to ours.
@ryannzander
ryannzander merged commit 665b4a3 into main Sep 10, 2026
2 checks passed
ryannzander added a commit that referenced this pull request Sep 11, 2026
This is the interaction the project was always for, and it turns out to need no
Apple Developer account at all — because the Flipper reads the tag, not the phone.
Core NFC needs a paid entitlement; the Flipper needs a USB cable.

It also needs no app on the device. The Flipper's CLI exposes NFC over USB, so the
laptop drives the read: no C app, and no need for an NFC module in the JS engine,
which does not have one. That was the blocker recorded in DECISIONS #17 and it
turns out to be avoidable.

SIGNER_KIND=nfc shows the proposal, runs the read, and signs when a tag lands.
ALLOWED_UIDS restricts which tags count; empty accepts any, which is the right
default before anyone has registered one.

What a tap proves is physical presence, not identity — a UID is readable and
cloneable by anyone who can hold a reader near it. It stands exactly where a
button press would. The comment says so, and the pitch should too.

The CLI verb and the UID format differ across firmware, so `nfc:probe` asks the
device rather than guessing: it prints the real `nfc` help, runs a read, and dumps
the raw bytes when a tag is tapped. flipper-check.sh now runs it as step 5.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
ryannzander added a commit that referenced this pull request Sep 13, 2026
* Add a start script that leaves the hub running

Background jobs started from a shell die with their parent, which meant the hub
kept vanishing between sessions and the phone would suddenly fail to connect for
no visible reason. nohup plus disown orphans it to init instead.

  ./scripts/start-hub.sh

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* Read the hub's keys from .env, not from defaults in the script

The start script carried the agent and relayer keys as inline defaults. The repo
checks testnet keys in on purpose, so this is not a leak — but a default in a
script is silently wrong in a way a missing variable is not. You cannot tell
whether you are running with your own key or one that shipped with the repo, and
the same shape would quietly accept a mainnet key later.

It now reads .env and refuses to start without SEPOLIA_RPC_URL, AGENT_KEY and
RELAYER_KEY, naming the file to fix. Consistent with the repo's own rule that
errors should be loud rather than degrade quietly.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* Tap a tag on the Flipper to approve

This is the interaction the project was always for, and it turns out to need no
Apple Developer account at all — because the Flipper reads the tag, not the phone.
Core NFC needs a paid entitlement; the Flipper needs a USB cable.

It also needs no app on the device. The Flipper's CLI exposes NFC over USB, so the
laptop drives the read: no C app, and no need for an NFC module in the JS engine,
which does not have one. That was the blocker recorded in DECISIONS #17 and it
turns out to be avoidable.

SIGNER_KIND=nfc shows the proposal, runs the read, and signs when a tag lands.
ALLOWED_UIDS restricts which tags count; empty accepts any, which is the right
default before anyone has registered one.

What a tap proves is physical presence, not identity — a UID is readable and
cloneable by anyone who can hold a reader near it. It stands exactly where a
button press would. The comment says so, and the pitch should too.

The CLI verb and the UID format differ across firmware, so `nfc:probe` asks the
device rather than guessing: it prints the real `nfc` help, runs a read, and dumps
the raw bytes when a tag is tapped. flipper-check.sh now runs it as step 5.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* start-hub: do not let a blank line in .env clobber an exported value

A restart silently did nothing: sourcing .env overwrote keys passed on the command
line with its own empty values, which then failed the required-variable check and
left the previous server running on the previous gate.

* Press kit, and make the app see proposals it did not create

Screenshots are captured from the running app against the live Sepolia deployment
rather than mocked, so the balances, the proposal id and the agent's replies are
all real.

Getting them exposed two genuine bugs. The app only knew about proposals created
during its own session, so relaunching while one was awaiting approval showed
nothing at all and the request sat invisible until it expired — it now takes the
hub's list as the source of truth. And the chat had no history, so every relaunch
looked like the agent had amnesia; the hub already kept the transcript and the app
now loads it.

The mark is three rings closing on a point of contact: something was touched, and
only then did it happen.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* A public page that recomputes the digest in your browser

site/ is plain HTML, CSS and ES modules with no build step, published to
GitHub Pages by .github/workflows/pages.yml on pushes to main.

The hero is the argument rather than a picture of it: site/digest.js is a
from-scratch keccak256 and EIP-712 encoder, so editing the transaction
recomputes the real proposal hash live. "Load the frozen vector" fills in
vectors/execute.json's inputs and reproduces its digest byte for byte, and
"Let the server lie" sends a hash for a different recipient so the panel
refuses the way the phone does. It also shows sha256(digest), which is what
the Enclave actually signs, and that matches vectors/p256.json.

digest.js is not a third implementation of the protocol to keep in sync — it
is checked against the frozen vector in front of the visitor. If that check
ever goes red, the page is wrong.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant