Security

Phosphor moves real money on mainnet. This page says what it defends against, the test behind each defence, what stays open, and how to check a release yourself. To report a problem privately, go to Report a vulnerability.

Check it yourself puts each of the wallet's security claims beside the code that enforces it, the test that proves it and the command that runs it, and shows how to check your download and your vault on NEAR.

Threat model#

This is the short version of Phosphor's security model, for anyone deciding whether to trust it with money. It says who Phosphor plans for, what the agent can and cannot do, what Phosphor defends against and the test that proves each defence, what stays open, what the invite tools guard, and how to check a release yourself. It describes version 0.10.19. The rest of the security model gives the detail behind each line.

Who it plans for#

  • The agent. It reads text from strangers all day: web pages, news, token names, memos, a venue's error text and other agents' words. Any of that text can try to talk it into a move. This is the main case.
  • A web page open in your browser, which can try to reach the app on 127.0.0.1.
  • Another account on this Mac, or a sandboxed app.
  • A program running as you. It can read your files, so it is only partly kept out. What stays open says how far it gets.
  • Someone between your Mac and a venue who changes a quote on its way.
  • A changed copy of the app: a file swapped inside it, or a fake update.

It does not plan for root, the kernel, or someone who controls the maintainer's GitHub account, which is enough to run a release with both signing keys (see What stays open).

What the agent can and cannot do#

The agent can:

  • read your balances, positions, rules and waiting moves;
  • price any move and propose it;
  • propose small moves that your rules run with no click: swaps, Hyperliquid deposits and trades at or under your click threshold ($100 by default), up to a daily total ($500 by default). See Policy;
  • search the web when it is Claude, and read a page only at an address a search returned or you typed;
  • switch the screen you see, and draw on the chart.

The agent cannot:

  • approve or refuse a move, run one your rules do not allow, or change a rule;
  • send money out, or withdraw from Hyperliquid, without your click, at any size;
  • move money out of your vault once it is on Touch ID: only you ask for a top-up, and it needs your click and your Touch ID;
  • see or use the wallet key;
  • read a page at an address it wrote itself;
  • see an invite code you put in the invite field.

A small move still waits for your click when its agent read text from outside Phosphor in that session (a page, the news, a chain read, a venue's words the app does not know, or words a marked agent wrote), when its agent was started outside Phosphor and you have not allowed it, when the agent asked for it on its own after a move failed, when it spends a coin the app cannot price, or when it is a swap the app cannot measure by dollar figures 1Click signed: a coin 1Click puts no price on, or a relay price with no signed 1Click quote beside it. Once your vault is on Touch ID, a move that spends more than your allowance holds waits for your click too.

What it defends against, and the test that proves it#

Each line names the tests that fail if the defence stops holding, and claims only what they prove. npm test runs the unit tests in tests/unit/, the injection suite and the lockdown suite. cargo test in src-tauri runs the shell's own tests, after npm run bundle has built the payload they check. npm run attack boots the app this checkout builds (npm run app:build) on a throwaway data folder and home, plays a hostile program in each case of tests/attack/cases/, and exits with an error when a defence does not hold. Three cases (04-update-signature, 12-xpc-vault, 30-xpc-sign-intent) have a half that needs a Developer ID build; add -- --app <Phosphor.app> to run it.

  • The agent approves its own move. No tool and no route in the agent's process decides. A decision needs the window token, which no route serves and no agent's environment holds. Proof: injection.test.ts; attack 14-secret-no-leak.
  • A web page drives the app. The app answers only requests addressed to 127.0.0.1 or localhost, and a write must carry a matching Origin, which a page cannot fake. Proof: security-hardening.test.ts.
  • Another account on this Mac, or a sandboxed app, reads your money. Every read takes the window token or a read key only your account can read. Proof: read-gate.test.ts.
  • A program running as you takes the agent's door with its secret file. Its seat counts as an agent started outside Phosphor, so every move it proposes waits until you allow it, however it names itself. Proof: attacks 06-agent-door, 15-seat-id-dodge.
  • A page, the news, a token name or memo on a chain, or a venue's error text talks the agent into a small move. The read marks that agent until its session ends, and any agent that reads its words is marked too. Every move a marked agent asks for waits for your click. A venue refusal the app knows reaches the agent as the app's own sentence, built from its numbers alone, and marks nothing. Proof: attacks 07-stranger-text (a chain read), 16-mark-laundering; web-read-tool.test.ts and web-read-gate.test.ts (a page, a search), stranger-reads.test.ts, stranger-relay.test.ts, venue-words-mark.test.ts, venue-refusals.test.ts.
  • A page gets your data sent out in a web address. A page is read only at an address a search returned or you typed. An address that carries your wallet's address or balances, or points at this Mac or your network, is refused. Proof: attacks 05-web-gate-addresses, 05-web-gate-dns, 05-web-gate-echo, 05-web-gate-redirect; web-gate.test.ts (your wallet's address and balances).
  • A small send to an attacker. Every send and every withdrawal waits for your click, whatever its size. Proof: send-gate.test.ts (sends and payouts), rail-wiring.test.ts (Hyperliquid withdrawals).
  • A program stops the backend and takes its port. The shell knows its backend by a fresh question only that backend can answer, and will not restart onto a port another program holds. Once it sees the backend gone, which takes up to two seconds, it posts nothing more that carries the window token, and its own locks, on a closed window and on a screen lock, post only to a backend whose process still runs. Proof: attack 17-port-takeover; the session_watch.rs tests under cargo test (the screen lock).
  • Code loaded into the process that holds the key. That process gets nothing from your environment but the settings Phosphor names, no debugger signal, no add-ons and no eval, and only 14 reviewed packages can load in it. Proof: attacks 01-env-injection, 02-inspector, 11-key-process; key-process-packages.test.ts.
  • A file changed inside the installed app. The shell checks the files against the digest built into the release, and starts nothing when they differ. Proof: attack 03-payload-tamper.
  • A fake update. An update comes only from this repository's release download for its version, must carry a version inside it newer than the one running, and must carry Phosphor's Developer ID, identifier and team; an archive that could unpack outside its folder is refused before it writes. Before any of that, the updater plugin (Tauri's code, not this repository's) checks the update's minisign signature; the tests show a release's signature passes that check, not that a forged one fails it. Proof: attack 04-update-signature (the Developer ID requirement); the update.rs tests under cargo test (the download, the version inside, the archive, and a bundle that passes minisign without the Developer ID refused).
  • Another program asks the Secure Enclave service to open the wallet, or to sign with the vault's Touch ID key. On a signed release the service answers only the signed Phosphor app, for the chip's operations too, and a build with no Team ID refuses every chip operation. Proof: attacks 12-xpc-vault, 30-xpc-sign-intent; vault-peer-check.test.ts (a foreign app refused, the app answered).
  • A wallet file swapped on disk for one wrapped to your enclave key. Anyone can wrap to that key: its public half is in the file. On a Developer ID build that carries the keychain entitlement, a bound wallet's file is checked against a pin only the vault service can write, before any Touch ID, so a substitute or an edited file is refused, and once a wallet on this Mac is bound, no device-bound key file opens on it. That last rule is for the whole Mac: the first wallet a signed release binds or makes, in any data folder, stops every device-bound wallet here from opening, the owner's own included, until it is restored from its backup. A demo makes no key and writes nothing to the keychain group, whatever build runs it. Such a build never falls back to a device key, and a signed release is such a build: its service carries the vault profile. Proof: vault-service.test.ts (the service's own rules against a stand-in keychain); vault-bind.test.ts and vault-relay.test.ts (a demo backend writes nothing to the keychain).
  • A wallet file swapped while it is being made or bound. Create, restore, the move from a password and the bind write the new file beside the live one, prove it with one Touch ID and commit it from the bytes held in memory, and only then put those bytes in place; the staged file is never read back. Every key in Phosphor's keychain home is committed, even after a start-up check that could not read the markers, and the window says Phosphor-only only once the service confirms the file in place. A crash leaves the old file whole or the new one committed, and the next start finishes or removes the staged file by the service's answer about it. A restore that is cancelled or refused leaves the wallet that was there exactly as it was; it used to shred it before the Touch ID, so the window token alone could wipe a wallet. Proof: vault-bind.test.ts (a staged file swapped during the touch is never pinned; a cancelled restore; the crash matrix, for a bind and a restore; a wallet made after a start-up probe that could not read the markers is committed all the same; Phosphor-only only for a file the service confirmed).
  • The backend asks the chip key to sign something its Touch ID dialog does not say. The vault service reads every payload itself, against a grammar that takes a move of known tokens from the vault to the allowance its marker pins, the removal of a key, or the empty rekey proof, and refuses everything else before the key is touched, so a refusal never raises a dialog. A transfer to any other receiver is refused, so no dialog ever names an account the backend chose, however it is spelled ("your-allowance.near" included). The dialog's sentence is the service's, written from what it read; the backend never sends one. Adding a key and switching predecessor auth back on are refused by name, whatever the payload wraps them in. The app reads the same grammar first (src/vault/chip-grammar.ts), so such a payload never reaches the service at all; the service stays the boundary. Proof: attack 31-chip-hostile-payloads (all 37 held in the app before the service is asked, and each refused by the service when asked straight); chip-service.test.ts (every refusal before the key with no signature asked for, each signed payload's sentence byte for byte, each signature verified in Node); intent-grammar.test.ts (every accept and refusal, 10 000 mutated payloads read the same as Node reads them, and the app's first reading refuses every one the grammar refuses); audit2-grammar-named-receiver.test.ts (a name registered to read like your allowance or your vault gets no dialog, and the app refuses it first).
  • A quote changed between your Mac and 1Click. A quote must echo the request as it was sent, carry 1Click's signature, and name the receiver the card shows. Proof: quote-request-echo.test.ts, quote-signature.test.ts, intents-spend.test.ts.
  • The solver relay prices a swap badly. The relay signs nothing it quotes, so its price is checked against a quote 1Click signed for the same swap. A swap that gives up more than 3 percent of its value by that quote's prices is refused, even after your click, and one with no signed price to check it by waits for your click. Proof: intents-relay.test.ts, swap-price-ask.test.ts.
  • The solver relay offers no price for a pair, or answers with an error or not at all. The same swap is drafted on the 1Click route before its card is priced, and held to that route's own checks: a signed quote, the echo of the request it sent, one fee account, the 3 percent cap and the pinned coins. A relay price past the cap is still refused, never routed around, and a swap never changes route once its card is drawn: a relay error at the click stays on the relay's route. Proof: swap-route.test.ts, intents-relay.test.ts.
  • The coin list changes under a card before you click. A card's coins are pinned when it lands. A list that names another coin at the click refuses the move, and nothing is signed. Proof: asset-pins.test.ts.
  • The screen locks with the wallet open. The wallet shuts at once. A move already signing gets its signature, then the key goes. During the first run the lock card covers it, so the recovery words leave the screen and the unlock field takes the keyboard; unlocking goes back to the same step. Proof: attacks 10-screen-lock-shell, 10-screen-lock-backend (the wallet shuts); lock-when-signed.test.ts (a move already signing); firstrun-e2e.test.ts and scripts/firstrun-lock-proof.ts (the first run).
  • One Touch ID opens more than it should. A Touch ID that shows your deposit address, your recovery phrase or, on a wallet with no phrase, your private key leaves a locked wallet locked. A Touch ID that approves a move on a locked wallet opens it for that move alone: no trading plan arms on it, even while the app starts, and your rules run again after the touch, before anything signs. Proof: vault-routes.test.ts (the address, the phrase and the key), approve-touch-lease.test.ts, touch-recheck.test.ts, runner-host.test.ts (the plans).
  • Freeze is pressed while a move is on its way. Every rail reads Freeze again as its last step before the key signs, and a plan reads it before it fires, arms or changes, so a move that passed its checks before you pressed Freeze signs nothing, whether you clicked it, touched it or your rules allowed it. What still signs while frozen: the trading runner's cancel, close, flatten and protect (Freeze's own close needs them), an invite claim (money coming in, signed by the code's key), and a signature released before you pressed it. Proof: freeze-at-signature.test.ts, runner-host.test.ts, and the last check's tests in intents-relay.test.ts, intents-native.test.ts, intents-spend.test.ts, hypercore-withdraw.test.ts and hypercore-deposit.test.ts.
  • An agent moves money while the vault moves to the chip. From the start of a move or a restore until NEAR says how it ended, nothing an agent asks for is proposed or signed: the door refuses a propose with nothing drafted, a row an agent filed is refused when it lands and again at its last check before the key, and a click on one waits until the move is over. A move bundle written down before a restart keeps agents waiting until NEAR can no longer run it; one whose deadline lies further ahead than any bundle the app signs (two minutes) is not the app's, and holds nothing back. The account the rails sign for changes in that call, and so does every key on the vault. Proof: wave3-wiring.test.ts, audit2-rekey-forged-journal.test.ts.
  • An agent reaches for the vault. No agent is offered a chip tool, every /api/vault write refuses what an agent holds, and only the shell, with its relay secret, takes or answers the vault service's requests. Proof: attacks 32-mcp-chip-reach, 33-relay-ops.
  • An agent takes money out of the vault. An agent cannot ask for a top-up or approve one: the vault's Touch ID key signs only after your click, and a move nobody clicked that finds the allowance short signs nothing. Proof: attack 34-agent-top-up.
  • A program rewrites state/vault.json. It cannot point the vault at another Touch ID key or bring the owner key back into the session: the service's pin and the chain decide. Proof: attack 35-vault-json-keyref-swap.
  • Freeze is pressed while the policy file is broken. Plans stop first, and the window says the switch could not be saved. Proof: kill-switch.test.ts.
  • Someone guesses your password. Five wrong tries start a wait, on unlock and on every other password check. Proof: keystore.test.ts.
  • An invite code reaches the agent or a log. The chat's guard, the backend's wall and the log tail hold back a code as it was issued, and as an editor or a chat app changes it. Proof: attacks 13-invite-composer-guard, 13-invite-no-leak (the guard and the wall); invite-guard-parity.test.ts (the log tail).
  • An invite claim is replayed, or paid to an edited address. One code makes one signature that can land (its rehearsal is dead a millisecond past the block it was tried at), and the money goes to the wallet's decrypted address, never to the plain copy on disk. Proof: attacks 13-invite-replay, 13-invite-replay-sim, 13-invite-tampered-header; invite-claim.test.ts (the rehearsal).
  • Words are planted in Claude Code's task list. The task tools are denied to the chat's agent and to the operator profile. Proof: attack 08-task-list.
  • A test or a scratch copy rewrites your agents' settings. Only the installed app, on its own data folder, adds Phosphor to them. Proof: attack 09-vendor-configs.

What stays open#

These are true of 0.10.19. Known limits gives each one with what it means for your money.

  • The key is in memory while the wallet is open. The backend holds the unwrapped key so it can sign. A program able to read that process's memory has it; the hardened runtime is there to refuse that. A lock by any door drops the key and every key or phrase you asked to see and the window has not read yet, and an unread one also goes when it expires, but copies left by a signature, an unlock, a new wallet or words or a key shown to you can stay in memory until it is reused, and the check behind Prove it outlives a lock for up to half an hour with the Mac awake. What closes it for the vault: the move to the chip (below). After it the vault answers only to this Mac's Touch ID key and the paper key, and the session holds the allowance key, the old fee account's key and the trading key, never the owner key; none of them reaches the vault. Until you move it, lock the wallet when you step away.
  • An older wallet's Touch ID key is bound to this Mac, not to Phosphor. A wallet made before the vault service carried its provisioning profile, or by a copy you build yourself, has a key another app running as you can ask to use, showing its own Touch ID dialog. Approve a Touch ID dialog only for something you started in Phosphor, and read its sentence. A signed release with the profile keeps the key of a wallet it makes in a keychain group only its vault service can reach, with a pin per bound wallet file and never a device key. What closes it for older wallets: Make it Phosphor-only in the Vault tab (the bind), one Touch ID, once the backup is proven.
  • Old copies of a wallet file still open on this Mac until the keys change. A bind moves the key into Phosphor's keychain, and Phosphor stops opening any device-bound file on this Mac, but it cannot reach a copy of the old file made before: a Time Machine backup, a sync folder, a copy you made. Any program running as you can load the old key from such a copy and ask for your Touch ID with its own dialog, and an older Phosphor build opens it too. Phosphor shreds only the copies it wrote itself (a write cut short), after the bound file first opens. Delete the others yourself. What closes it for the vault: the move to the chip, after which the old key is no key of the vault. An old copy still holds the allowance and Hyperliquid.
  • The paper is the only key that opens the vault away from this Mac. After the move the vault answers to this Mac's Touch ID key and the 24-word paper key, and to nothing else: the key backup alone no longer reaches it, by design. If the Mac is lost and the paper is lost or wrong, the vault is lost. Phosphor has you type three of the words back before anything moves (all 24 after a lock, the tab left or a restart), and the paper signs an empty proof on chain in the same call that adds it, but it cannot see the paper you wrote, so a slip in a word it did not ask for shows only at a restore. A restore on a new Mac takes both: the key backup brings back the vault's address, the allowance and Hyperliquid, and the paper brings back the vault. Keep the paper like cash, apart from the key backup. The paper is also an ordinary EVM key at m/44'/60'/0'/0/0, so it signs for the vault in any EVM wallet if Phosphor is gone. What closes it: nothing in this build.
  • The key backup also controls the allowance. The allowance key is derived from the owner key, so whoever holds the key backup holds the allowance (its size plus 10 percent, $110 at the default, more while a move is under way), any NEAR left in the old fee account a 0.10.16 wallet paid into, and Hyperliquid. Keep it like cash. What closes it: nothing planned; deriving the keys is what keeps one backup instead of several.
  • After the move, your wallet's words still open the allowance and Hyperliquid. The move takes your wallet's key off the vault, not out of use: the recovery phrase or private key the Vault tab still shows after the move no longer opens the vault, but the allowance key is derived from it, and it still owns your Hyperliquid account. The reveal says so beside the words. Whoever reads those words can spend the allowance and your Hyperliquid collateral. What closes it: nothing planned; show the words only to write your backup.
  • A program running as you during the move sees what the move sees. The owner key and the paper sign the call that changes the vault's keys in the app, not in the chip, so such a program could read the paper's words as you type them, pin a wrong allowance, or add a key of its own in the same call. The chip refuses to add a key at all, so after the move a program can no longer do that. The done screen shows the vault, the allowance and the paper key the move pinned, and the Vault tab reads the vault's keys from the chain. What closes it: an audit by a third party, which is planned and not done, and a signer that builds that call itself.
  • A bound wallet's key is one keychain item. After the bind, and for every wallet a signed release makes, the key lives in one item in the vault's keychain group and in no file, so no copy of the wallet file opens without it. If the item is gone (the Mac erased or replaced, its keychain reset), the backup is the only way back, which is why the bind asks for a proven one. A key backup holds the EVM key alone: an older wallet's NEAR, Solana and trading keys live only in the bound file and go with the item. The app never shows or spends from those NEAR and Solana addresses, and a trading key is approved again. What closes it: nothing in this build; the backup kept off this Mac is the way back.
  • A chip sentence names a token by its ticker, not its chain. "move 5.00 USDC" does not say whether that is USDC on NEAR or USDC bridged from Ethereum; both are in the chip's token table, and a payload names each ticker once, so one sentence never says USDC twice. The value is the same either way. What closes it: nothing planned.
  • A coin outside the chip's token table is said in base units. The chip trusts a ticker and decimals only from its own table, built into the vault service. Any other coin still leaves the vault, so nothing is ever stuck there, but its sentence says the raw base units and the coin's id ("move 641,867,112,059 units of nep245:v2_1.omni.hot.tg:1117_"), because a ticker or decimals from the backend could be a lie. Read the leading digits against the move card. The receiver is still only your pinned allowance. What closes it: nothing planned.
  • One marker refuses every device-bound file on the Mac. Markers are one pool for the whole Mac, and while any exists the service refuses every device-bound key file. So the first wallet a signed release binds, makes or restores, from any copy and in any data folder, stops every device-bound wallet on the Mac from opening in Phosphor, a live one in another folder included, until it is restored from its backup as a bound wallet. The lock card says why and offers that restore. A demo writes nothing to the group, so a demo never does it. What closes it: binding every wallet on the Mac. The rule stays, because it is what keeps a device-bound file swapped in for a bound wallet from opening.
  • A readable key file is trusted only on a Mac known to have no Phosphor-only wallet. A readable keys.json is a wallet from before encryption, which nothing pins. Phosphor reads it as your wallet, and offers to encrypt it, only once its vault service has read this Mac's keychain and found no Phosphor-only wallet. Everywhere else it leaves the file closed (served unverified, never encrypted): on a Mac where any wallet is Phosphor-only, where the lock card points to the backup, and in a copy whose vault service cannot read the keychain (the development shell, npm run tauri dev, or a copy built without the Developer ID), which cannot tell and points to the downloaded app or the backup. The one exception is the bare backend (npm run app): it has no vault service to ask, so it reads the file as your wallet, as it always has.
  • A program running as you can read and propose. It can read the read key and the agent's secret file, and a seat taken with that file waits for your click until you allow it. It can also read the secret that an agent Phosphor started carries in its environment, and a move filed with that one under your click threshold runs with no click. If that worries you, set the threshold to zero: then every move waits for you.
  • A program that takes the backend's port gets two seconds of the window. If a program running as you stops the backend and takes its port, the shell needs up to two seconds to see it. In that time the open window still posts to the port, a click or a password typed into Unlock included. The window token in those posts is dead by then: the shell will not restart onto a port another program holds.
  • Names a venue lists reach the agent unmarked. 1Click's coin names, the bridge's asset names and its deposit memo, and the account names in a swap's record do not mark the agent that reads them. A venue that lies, or someone who breaks HTTPS to it, can put words there, and the agent's next small move still runs on your rules alone. A token name or memo read on a chain marks as usual.
  • A fee can hide inside fixed floors. 1Click does not sign its fee field, and the solver relay signs nothing it quotes. Someone who can change a quote on its way, which takes breaking HTTPS, can take up to a move's loss floor: 3 percent of a swap on either route, 1 percent of a send, 3 percent of a payout, 5 percent of a Hyperliquid deposit, 0.25 USDC plus 0.4 percent of a Hyperliquid withdrawal, and 1 percent of an invite claim through 1Click. The relay itself can price a swap on its route up to 3 percent worse. On both swap routes the 3 percent is measured by dollar figures 1Click signed (on the relay route, in a quote for the same swap), so it trusts 1Click's prices: a 1Click that signs false figures is not held to it. A swap of a coin 1Click puts no dollar figure on has no cap at all: it waits for your click on either route, and the amount on its card is the only check. What closes it: the venues signing what they quote, the fee included.
  • A relay swap with no signed price has no cap once you click it. When no quote 1Click signed for the same swap comes back (1Click did not answer in time, or someone who breaks HTTPS dropped its answer), a relay swap waits for your click whatever its size, and its card says Phosphor could not check the price. Once you click, it runs at the relay's price, held only to what its card says you get at least, so the card is the only check. What closes it: the relay signing what it quotes.
  • A relay that answers nothing or an error moves a swap to 1Click. Whoever runs the relay can send a swap to 1Click by answering nothing, or an error. There 1Click holds the coins for the seconds until it delivers, where the relay moves both sides at once. Both venues have one operator, already trusted for the price on both routes. What closes it: a click on every swap that leaves the relay.
  • The quote check fails closed. If 1Click starts sending back a field this app does not know, every quote is refused until Phosphor is updated. Nothing is signed, and your money stays where it is.
  • A release signs what its build job made. Before signing, the release checks that the payload's own files match the tagged source and that every program carries only the committed entitlements, and that the vault service carries the five chip ops and the grammar's newest rule (a service built without them, or from an older grammar, is never signed; that is a check of a few strings, not of how the program was built, and since the move that program is the vault's last check). Nothing checks the rest of the disk image beside the app. It cannot vouch for the compiled programs (the shell, the bundled Node, the Secure Enclave service) or for the installed packages, and the release build does not repeat CI's check of each package's registry signature. What closes it: a build anyone can reproduce byte for byte.
  • A release rests on one GitHub account. The signing keys, the Developer ID and the update key, are read only in the release environment, which lets only v* tags in and waits for the maintainer's approval; the site upload's release-site environment holds the Blob token alone and starts only after that approved job. These are repository settings, not code, so the source cannot show them; the GitHub API can (Known limits has the commands). The maintainer's account, the only one that can push, approves each release and can change those rules. What closes it: a second approver, or signing that needs a device outside GitHub.
  • An armed trading plan outlives a lock. Its trading key can place and cancel orders until the plan expires, seven days at most, and can never withdraw or transfer. Freeze stops every plan from placing anything new.
  • The audit log is evidence, not a lock. It is hash-chained, so a hand edit to one entry shows. A program running as you can rewrite the whole file, and nothing on chain records that a Touch ID happened.
  • The venues are not Phosphor's. The NEAR Intents verifier can be upgraded by its owners, and an invite claim on the 1Click route rests on 1Click delivering.
  • NEAR Intents' admins can switch predecessor auth back on. The move turns auth by predecessor id off for the vault (the NEAR door, in the Vault tab), so your wallet's key cannot act for it through the account's NEAR wallet contract. The verifier's admins (the DAO and the roles UnrestrictedAccountUnlocker and UnrestrictedAccountManager) can turn it back on for any account (force_enable_auth_by_predecessor_ids), which would let that key reach a moved vault again. The Vault tab reads the flag from the chain and says when the door is open, with what to do, so a forced flip is visible. What closes it: nothing Phosphor controls; the switch is the verifier's.
  • A backend compromised after the move writes every Touch ID dialog but the chip's. The owner key's unwrap and the presence ask carry sentences the backend writes, and such a backend can ask for the paper on a screen of its own: Phosphor asks for words of the paper only in a move or a restore you started. Only a chip dialog moves money out of the vault, and the vault service writes that one from the payload it read. What closes it: nothing in this build.
  • This Mac's login password answers the chip's dialog. The chip key asks for you on every use, by Touch ID or the login password, so a stolen Mac and its password move the vault. What closes it: nothing in this build; keep the login password strong and the Mac locked.
  • Every chain read trusts one NEAR RPC. The app reads NEAR through FastNEAR alone. A lying RPC can make a top-up whose call landed look dead: the settle trusts a final block whose time is not tied to its hash, and asks no second RPC before it calls a bundle dead, so the next approval signs again, and the money moves twice inside your own accounts. It can make a stopped move look done only by lying about the whole move at one block (this Mac's key and the paper on, the owner key and the NEAR door off), and it can always hide a forced switch of the NEAR door from the Vault tab. What closes it: a second RPC asked before any verdict that lets a signature through, and the final height recorded at the send.
  • The chip's sentence is 120 characters at most, and the dialog must show all of it. A payload whose sentence would run longer is refused, but no real Touch ID dialog has been read in a test yet. What closes it: a session on a real Mac (session S) that reads each sentence in the system dialog.
  • An unlock answered while the gate's chain read is out keeps the owner key out all session. A stale chip marker (a move stopped after the commit), or no marker known at the start (its status refused), has the owner-key gate ask NEAR at the open. When the answer comes after the unlock, a wallet that never moved opens without its owner key, and every move it signs is refused as if the vault had moved, until a lock and an unlock. What closes it: asking the chain before the unlock is answered.
  • The trading key's counter in vault.json can be rolled back. A program running as you that rolls it back makes the next Allow trading approve an address the venue approved before, so that key's signed actions inside the venue's nonce window (about two days) could replay; a counter of 2^31 - 1 makes every approval ask for a reopen. It cannot make the app trade with or approve a key someone else holds: every trading key is derived from the owner key. What closes it: deriving each trading key under its approval's own nonce.
  • A signed test session leaves a signing window open. A maintainer's signed run of the release harness unlocks its throwaway keychain for that run, its key trusts codesign, and the import passes the p12 password on a command line, so a program running as the maintainer could sign with the Developer ID in that window. It adds nothing while such a program can already read the signing key's file. What closes it: moving the signing keys off the maintainer's Mac first, and that session waits for it.
  • The web gate lets a little through. Which pages the agent chooses to read can tell those sites a few bits each, at most 12 pages a session and 3 a site.
  • Grok has no web search. With Grok in the chat, the agent cannot search the web. Give it a link and it reads that page.
  • An invite code changed by hand can reach the agent. The guards catch a code as it was issued and as an editor or a chat app changes it, not one someone retyped with a slip. Paste codes only into the invite field.
  • A lying NEAR RPC can make a claim look failed. An RPC that stamps a final block up to two minutes ahead can run a claim's rehearsal. It pays only this wallet, but the window says the claim failed until the app's next start finds the money and marks it claimed.
  • An agent can stop a worker it did not start. agent_jobs stops any worker it names, whoever started it. That ends the worker's job and moves no money.

The invite tools#

npm run invite is a separate program for whoever hands out invite codes (Money has every command). It never reads the wallet's key file. What it guards: the treasury, T, which funds each batch; each code's key until someone claims it; and the invite file that holds both, ~/.phosphor-invites/invites.enc.json. What it trusts: the solver relay to publish a batch, a reclaim or a withdraw, which it can send or drop but not change, since the signed bytes name every account they pay; 1Click to fill a convert; and a NEAR RPC to rehearse each payload before the real one is signed.

  • Someone reads or edits the invite file. It is encrypted (AES-256-GCM) under a key scrypt makes from a passphrase of at least 20 characters, typed on every run and stored nowhere. A wrong passphrase or a changed byte stops every command before anything is read or signed, and the file is never on disk in the clear. Proof: invite-file.test.ts, invite-operator.test.ts.
  • A batch signs twice. The codes are in the file before the payload is signed, a stopped run is finished with the same bytes, and a batch the relay turned away is closed only once its signature has expired on NEAR. Proof: invite-operator.test.ts.
  • A convert pays someone else or gives up too much. The quote must echo the request, carry 1Click's signature, pay out to T and refund to T, and the payload may pay only the deposit address that quote names. A convert gives up at most 1 percent, and its signed bytes are in the file before 1Click sees them. Proof: invite-convert.test.ts.
  • A withdraw goes to a look-alike address. You type back the first six and last six characters of the address before anything is signed. Proof: invite-operator.test.ts.

What stays open:

  • The terminal rule is a speed bump. The passphrase is the wall. Every command refuses input that is not a terminal, so a script or an agent does not run it by accident. A program that pretends to be a terminal gets past that and reads what the terminal shows, the codes' links included. Type the passphrase only in your own Terminal.
  • 1Click's word closes a convert. 1Click's status is unsigned. A lying 1Click, or someone who breaks HTTPS to it, can close a convert before its money reaches T by saying it succeeded (a refund counts only once it shows on T). invite-proof.ts sweep can then call a proof file done while money may still arrive on the key that file alone holds.
  • A fast Mac clock and a lying RPC can run a rehearsal. With the Mac's clock fast and the RPC lying about the time, a batch's rehearsal can run beside its real payload when T holds twice the batch: each code then holds twice its amount, a claim takes all of it, and reclaim takes back what nobody claimed. A convert's rehearsal run that way pays 1Click's deposit address, and the way back is 1Click delivering or refunding to T.
  • A fee can hide inside a convert. Up to its 1 percent floor, as with the app's own floors.
  • The proof script keeps its keys in the clear. scripts/invite-proof.ts asks for no passphrase, so its file holds its throwaway keys in the clear, and release-code prints a live code to stdout. Put in only what you are ready to lose, sweep it, then delete the file.

Check a release yourself#

You can check a download with tools that are not Phosphor's. The disk image should match the SHA-256 on its release page:

shasum -a 256 ~/Downloads/Phosphor-macOS-arm64.dmg

It should be built by this repository's release workflow, from the tag of its version:

gh attestation verify ~/Downloads/Phosphor-macOS-arm64.dmg --repo karimbabasf/phosphor \
  --signer-workflow karimbabasf/phosphor/.github/workflows/release.yml \
  --source-ref refs/tags/v0.10.19

With --repo alone, the check also passes for a file any other workflow in this repository vouched for, on any branch.

Its files should be the ones the release's source builds. The app's own report, the tools macOS ships and the source each give a payload digest, and the three agree when your copy is the one the tag builds:

/Applications/Phosphor.app/Contents/MacOS/phosphor-desktop --payload-digest

cd /Applications/Phosphor.app/Contents/Resources/phosphor
find . -type f ! -name .DS_Store | sed 's|^\./||' | LC_ALL=C sort | tr '\n' '\0' | xargs -0 shasum -a 256 | shasum -a 256

git clone --depth 1 --branch v0.10.19 https://github.com/karimbabasf/phosphor.git
cd phosphor && npm run bundle

Use the tag of the version you have. --payload-digest is new in 0.10.13: an older copy does not know it and opens the app instead, so on an older version use the other two. The last step needs Node 24 and Xcode's command line tools, and prints payload: digest with the value. The digest covers the payload's files, not the compiled programs (see the release item above). Security has the full steps.

What has been done, and what has not#

  • The code is public under FSL-1.1-MIT, so anyone can read what signs.
  • The test suite (unit, injection and lockdown) runs on every push to main and every pull request, beside a secret sweep and a check of every package's registry signature. npm run attack plays a hostile program on your Mac against a built copy of the app, 32 cases, and anyone can run it.
  • Since version 0.10.12 every release is signed with an Apple Developer ID and notarized by Apple.
  • Only the latest commit on main is supported. There are no backports.
  • There has been no third-party audit. Until one is published here, treat the app as reviewed by its author alone, and size what you put in it accordingly.

Report a vulnerability#

Do not open a public issue for anything that could move, expose or lose funds. Use GitHub private vulnerability reporting: it is private to the maintainer until an advisory is published. If you cannot use it, open a public issue that asks for a private channel and says nothing technical.

A useful report carries the version or commit, the configuration that was live (network, policy), the exact steps, what you expected the policy engine to do, and what it did.

What counts#

  • Anything that gets a write executed without the human click the policy required.
  • Anything that lets the connected agent reach approval, execution or policy directly.
  • Key material leaving the machine, landing in a log, or entering the git tree.
  • Forged, dropped or reordered entries in the audit log, or a log that records a human decision when no person clicked.
  • A rule (budget, allowlist, threshold, composition limit, kill switch) that can be bypassed or silently skipped.
  • A proposal whose displayed facts differ from what gets signed, on any screen.
  • Prompt injection that reaches a real capability rather than only the agent's text.
  • A program running as you that breaks a boundary the threat model says holds against one: the click, the window token, the read gate, the update check, or the Secure Enclave service's caller check.

Out of scope: bugs in third-party protocols, chains, bridges, venues or RPC providers (report those to them); losses caused by your own configuration, your own approval, or a rule that did what it said; anything that needs an attacker who already has your key file, root, or code running as you that the security model says it does not defend against; and the limits the threat model already states as open.

What to expect#

Best effort, from one person, with no promised timeline. There is no bug bounty and no payment. Please allow 90 days for a fix before publishing, or less if the issue is already being exploited. You are credited in the advisory if you want to be.

Contact#

Private reports through GitHub, as above. Anything else: founder@karimbabasf.com.