How the phone signer works.
This page is for engineers checking the design. Each statement comes from the wallet’s source and design documents. The repository is not public yet, so files, commands and constants are named for checking each statement once it is.
The signer is pre-release and tested on regtest only, and sign.satsgate.io is not live. The phone signer covers what an owner does.
Treasury script
Each treasury output is a taproot output from this descriptor, built in packages/treasury/src/descriptor.ts:
tr(H,{and_v(v:pk(P),pk(A)),{and_v(v:pk(P),pk(O)),and_v(v:pk(O),older(12960))}})
# H 50929b74c1a04954b78b4b6035e97a5e078a5a0f28ec96d547bfee9ace803ac0
# BIP-341's point H. Its private key is unknown, so the key path cannot be used.
# A the agent key, from the wallet seed at m/87'/coin'/0'
# P the policy key, made on the phone
# O the recovery key, kept offline
# Each key is ranged over /0/* for receiving and /1/* for change.PandA: refills and treasury purchases. It is the first leaf because it is the routine path and the cheapest to reveal.PandO: a deliberate owner sweep without the agent.Oafterolder(12960): the owner’s escape. The delay is relative, so it runs per output from that output’s confirmation, and new change starts its own clock.- No leaf holds
AandOtogether, and none takes a single key without the delay. A stolen policy key alone cannot spend. - The service refuses a configuration unless the three keys come from three separate seeds. Regtest takes any delay from 1 to 65,535 blocks so tests need not wait; elsewhere it is 12,960.
- Every permitted path is spent against Bitcoin Core on regtest. Core refuses the forbidden ones, with witnesses forged for agent and recovery together, the recovery key before the delay and each key alone.
The signer page
packages/signer-web is plain TypeScript and DOM code. esbuild bundles it into one ES2022 module, app.js, with minification off, so the served script can be read. BDK’s WebAssembly, bdk.wasm, runs the transaction checks. The build records its SHA-256 in the bundle, and the page checks that hash before it runs the module. The same commit gives the same bytes wherever it is built.
GitHub Pages cannot set response headers, so the content security policy is a meta element. Scripts load from the page’s own origin alone:
default-src 'none'; script-src 'self' 'wasm-unsafe-eval'; style-src 'self';
img-src 'self'; connect-src 'self' wss:; manifest-src 'self';
base-uri 'none'; form-action 'none'- The page refuses to run inside another page's frame.
- Every value that arrives through the relays is set as text, never as markup.
- The page shows the commit it was built from.
- Strict transport security for the subdomain is to come from
satsgate.io’s own header, withincludeSubDomains.
Key and vault
- The policy seed is a BIP-39 mnemonic of 24 words from 256 bits of entropy, made in the signer page. It shows the words when it makes them, and again only after the vault is opened.
src/vault.tsseals the words with AES-GCM under a 256-bit key, a fresh 96-bit nonce and the labelsatsgate vault 1as associated data.- Passkey vault: the WebAuthn PRF extension, evaluated on a random 32-byte input with user verification required. Its 32-byte output goes through HKDF-SHA-256 and is never used as the key directly.
- Passphrase vault, for a passkey without PRF: PBKDF2-HMAC-SHA-256 with 600,000 rounds and a random 16-byte salt, over the NFKC form of a passphrase of at least 12 characters.
- The vault stays in the browser's storage on the phone. Pairing sends only the policy account's public key, and the service refuses an answer that carries a private key.
Pairing
satsgate owner pair shows the offer as a QR code of a link to https://sign.satsgate.io/#pair= followed by the offer as base64url JSON. The offer travels in the fragment, which a browser never sends to a server. It holds:
pairingId, 16 random bytes, which every later message carrieswalletId,networkandrole, which ispolicyorrecoveryservice, the service’s first transport key, x-onlyrelays, one to eightwss://URLsexpiresAt, 15 minutes after the offer is made
The phone answers through the relays with pair, carrying its public role key sealed with a transport key of its own. Both ends then show the code from pairingCode in packages/relay/src/pairing.ts:
# Both ends compute this once the phone has answered.
digest = SHA-256("satsgate pairing code 1 <pairingId> <service key> <signer key>")
code = first 4 bytes of digest, big-endian, mod 1,000,000
# Shown as six digits, zero-padded, in two groups of three.- The code covers the signer's first key, so the codes match only when the service heard from the device the owner is holding. The owner types yes on the agent's machine only then.
- Two devices answering, or one device changing its answer, spoils the offer, and the owner starts again.
- Once confirmed, the service sends
paired, with its VAPID public key for pushes, thentreasury. The phone records the treasury only if its own key holds the policy role, every treasury descriptor rebuilds from the keys and the delay, the allowance is one taproot account whose key gives the wallet id, and that id is the offer’s. - The recovery signer pairs with a file, since its key stays offline. The signer page refuses a recovery offer.
- On iOS and iPadOS, a Home Screen page keeps storage apart from Safari's and only it receives pushes. The owner adds the page to the Home Screen first, and the page reads the QR code from the camera on the device with jsQR 1.4.0, or takes a pasted link.
Transport
packages/relay carries six messages: pair, paired, treasury, request, reply and subscribe. Each is NIP-44 encrypted inside a NIP-59 gift wrap, kind 1059, and posted to every relay the owner chose: wss://nos.lol and wss://relay.primal.net unless the owner names others.
- A relay sees a throwaway author, a timestamp up to two days early and the recipient's key. It sees nothing of the message.
- Transport keys are made for one pairing. They are never a Bitcoin key or the owner's Nostr identity.
openaccepts a message only from a key the end holds for its peer, or the next one introduced by the newest it holds, under the pairing id. The sender must have made it within the last 8 days and no more than 10 minutes ahead, by its own clock.- NIP-44 has no forward secrecy: whoever holds a secret can read every message sealed to it that a relay still keeps. So each end moves to a new key every 30 days, once the peer has used its current one. The old key signs the pairing, the generation and the new key, and the old secret is deleted once its successor has been in use longer than a message can be old.
- A wrap over 131,072 bytes is refused, the smallest limit the default relays publish. A request too large for the relays, or whose signed reply would be, goes as a file.
- The service sends each pending request when it is made and again every ten minutes until it is decided or expires, since a relay can drop a message. It looks for replies every 30 seconds while it runs. A message two relays deliver is acted on once.
Review and signing
The signer page runs the review in @satsgate/signer/review, the same code as the satsgate-signer command, with the BDK validator. It trusts its own copy of the treasury, never the request. In order:
- The format, wallet and network, and the window by the phone's clock. A refill request lasts 24 hours by default and a week at most. A request that carries a purchase expires no later than its quote.
- The ledger: a new request id, or a body seen before, which gets the same decision. A different body under a known id is refused.
- The kind:
treasury_refillortreasury_purchase. The page refuses the others. - Inputs: each spends a treasury output. The transaction that created it travels with the request and counts only when its hash is the txid the input names. The PSBT must state each input's value and script truthfully, with a safe sighash.
- Outputs: each is an approved recipient at the approved amount, matched one to one, or change to the treasury's change keychain. For a refill, each recipient must be a receive address of the paired allowance.
- Fee: within the request's ceiling, which for a refill is the exact fee. The PSBT's unsigned txid must be the one the request names.
- Path: each input carries the agent leaf alone, with the derivations of the agent and policy keys and no others, at the position of the output it spends. A position in the PSBT is only where to look. The validator derives the script there from the signer's own descriptors, so a false claim finds nothing.
The screen shows the totals the validator computed from the transaction. For a treasury purchase, the merchant’s origin, action and any description sit under “What the wallet service says it is for”, with the line “This page cannot check these details.”
Approving opens the vault and signs the agent leaf alone. The page checks that each signature its key owes is present and valid, records the decision against the hash of the request it showed, then sends a reply with requestId, requestHash, decision, decidedAt and the signed PSBT. The service uses a reply only if it answers the saved request’s exact contents, signs the same transaction and carries a valid policy signature on every input. It adds the agent’s signature, saves the finished transaction and only then broadcasts it.
Notifications
- The service wakes the phone with an empty Web Push: a POST with
Content-Length: 0, a 24-hour TTL and high urgency, signed with an ES256 VAPID token. The P-256 key is the service’s own, made the first time it is needed and kept in its records. - The push carries nothing, so no push encryption keys are needed. The phone shows “A request is waiting for your approval” and fetches the request itself through the relays.
- A push service learns only that something was sent, and when.
- The service calls only https push endpoints on named hosts, and drops a subscription its push service reports gone.
- Pushes reach a Chrome tab on Android. On iOS and iPadOS they reach only a page opened from the Home Screen.
The served page
Before a key is made, the welcome and pairing screens show this notice beside the commit the page was built from:
This page is served by sign.satsgate.io. If that site, or the code it serves, were compromised, it could take your policy key while the vault is open, or show you one transaction and sign another. Encryption, a passkey, a Home Screen icon and open source do not remove that risk.
The policy key alone cannot move the treasury: every spend this page signs also needs the agent’s key. Treat a phone or a page you no longer trust as a lost key all the same, and move the treasury with your recovery signer.
The owner command line, satsgate-signer, signs without this site, and your words work there too.
How a release is built and checked:
.github/workflows/signer-web.ymlruns only for asigner-web/v*tag. It builds the page twice and fails if the builds differ, refuses a build from a changed checkout, attests every built file with GitHub’s build provenance and deploys exactly those files to GitHub Pages.- The build writes
release.json: the commit and every file’s SHA-256. satsgate-signer verify-webfetchesrelease.jsonand every file it names from the live site, the root included, and checks each hash. With--against packages/signer-web/dist, it compares the live manifest with a local build of the same commit..github/workflows/signer-web-live.ymlruns every day. It checks the live files againstrelease.json, then checks thatrelease.jsonwas attested by the release workflow for asigner-web/v*tag. A failed run is the alarm.- Repository settings to be made before the first release let Pages deploy only from those tags, and let only maintainers create them.
These checks see what the site serves to the checker. They cannot prove that a compromised origin serves the reviewed build to every phone, which is why the notice stands.
Recovery
satsgate-signer init --role policy --restorereads the 24 words like a passphrase, so they never show on the screen, and gives the same key.- Record the treasury with
satsgate-signer treasury. Then requests travel as files:satsgate owner approval exporton the agent’s machine,satsgate-signer signon the owner’s computer, andsatsgate owner approval replyback on the agent’s machine. No Satsgate site or relay is involved. - Restoring does not revoke the phone's copy.
- To stop trusting a policy key, sweep the treasury with the policy and recovery keys,
satsgate-signer recovery plan --path sweep, or with the recovery key alone for outputs at least 12,960 blocks old,--path recovery. Both use the owner’s own Bitcoin Core node and no part of Satsgate. - A treasury is set up once. A new policy key needs a new treasury and an on-chain transfer, which the service does not offer yet.
Status and limits
- Pre-release, tested on regtest only.
- On 16 October 2026 the signer page ran on an Android 16 phone with Chrome 155.0.8059.41, and on an iPad with iPadOS 26.6.2, in Safari and as a Home Screen page. It was a local build served through an HTTPS tunnel, paired with a wallet service on a regtest node through
wss://nos.lolandwss://relay.primal.net. - Each device paired by QR code, kept its key in a passkey vault and approved a refill that the service completed and confirmed. Notifications arrived in Chrome and in the Home Screen page; Safari allows push only to a Home Screen page. On the iPad the first notification did not appear; once its notification settings were checked, the next ones did.
- On the Android phone, clearing the page’s storage left no key, and
satsgate-signer init --restoretook the words and signed the next refill from a file. The iPhone run is still owed. - sign.satsgate.io is not live. The release workflow is written, and waits on the repository being made public, which attestations and Pages from a workflow need, and on the Pages and DNS settings.
- The signer page signs refills and treasury purchases only. It refuses the other two kinds in the approval format,
allowance_exceptionandpolicy_change. - The phone keeps its record of decisions in the browser's storage, so cleared storage loses it. Where to keep that record so it survives is an open choice.
- The independent security review the build plan requires is a gate for the full release, which comes after this phase.
The wallet covers budgets, install modes and the rest of recovery.