A dapp could request a signature and then call alert(): the page's
sheet was raised over the approval, and closing it focused the page
again while the approval's buttons were already armed, so a
double-click on the sheet's OK landed on Approve. Page dialogs and
Theseus's own sheets are now held until the approval or unlock prompt
is answered, and nothing hands focus to the page while one is open.
bcnr.installExtension is in every page's main world, and install links
were honoured from any page and any frame, with no user gesture. Any
site could raise the install sheet timed so that a double-click landed
on Install, whose two buttons are always in the same place; an
installed community extension runs in the main process at once. The
call and the links now work only from theseus.x's top frame, the call
needs a real click (checked in the isolated world), and Theseus's own
sheets ignore every choice except Cancel for 800 ms, like the approval
overlay.
On a host with api.vault.pin, Aegis no longer keeps a PIN of its own:
its lock-screen, reveal and transaction pads send the digits to the
Theseus vault PIN, which is checked in main against the one strike
counter Settings, the unlock prompt and Pithos also use. The vault is
opened there, and the panel receives a single-use proof (two minutes)
where it used to receive the master password; vaultUnlock, revealSecret
and pinGateSatisfied accept it, still bound to the request it was
entered for. The master password no longer passes through Aegis or its
panel for a PIN entry.
An existing Aegis PIN moves to the vault PIN on its first correct entry.
If Theseus already has a different PIN, the user is asked once which
one to keep. Setting and removing the PIN act on the vault PIN, and
Settings says that it is shared. Hosts without the API (Theseus
0.3.74-0.3.76) keep Aegis's own PIN exactly as before.
Theseus and Aegis each wrapped the same master password under their
own PIN: two offline targets, two guess budgets, and two PINs to keep
in step. The vault PIN is now the only one. Built-in add-ons get
api.vault.pin {status, unlock, set, clear} (advertised by
features.vaultPin); unlock(pin) opens the vault in main and answers
only { ok } or why not, so the master password stays in main.
The policy is the one Aegis's PIN screens describe: five wrong PINs
lock the PIN for 15 minutes, every further wrong one locks it again,
and the master password always works. The unlock prompt uses the same
PIN pad and the same wording as Aegis, and Settings says so.
will-navigate and the window-open handler routed every wiz:// link to
the wallet with the top-level URL's origin, whichever frame raised it.
An ad iframe on a trusted dapp could window.open() a pairing URI and
the pairing prompt would name the trusted site; one click on Pair gave
the attacker the wallet's xpubs and a standing signing channel. A
link must now come from the main frame (navigation initiator, or for
window.open the referrer's origin), and only on pages where the wallet
is allowed by the inject policy.
- Electrum: a reply larger than 8 MB ends the connection; a server
streaming without newlines used to grow the buffer without bound and
re-parse it on every chunk, on the Electron main thread.
- Overlay amounts are formatted from integer strings: 0.1 ETH read
0.100000000000000006, and 1 ETH + 1 wei read 1.
- The panel escapes a broadcast txid shown without an explorer link.
- A TPM refusal hands back only its own PIN attempt, not the count
before it, which could undo guesses made in parallel.
- BCH WIF imports must carry this network's version byte and a valid
compression flag; a 64-byte Solana keypair must have a public half
that matches its secret half.
- WizardConnect: the overlay shows the network fee (exact, as
SIGHASH_UTXOS commits to the inputs), marks outputs to this wallet's
own addresses, lists every token input and every output instead of
"... and N more" (more than 30 is refused), and a very high fee or
token inputs get the danger button.
- DAI permits: expiry 0 reads "never expires", and the allowed flag is
read the way the encoder signs it.
Dapp calls resolved their wallet afresh every time: Ethereum took the
first ready wallet on the site's chain, Solana and Tron the sidebar
selection. A site connected to wallet B was served wallet A, choosing
another wallet in the sidebar re-pointed every connected Solana/Tron
site and told it the new address, and a connected site could list
every EVM wallet's address by looping wallet_switchEthereumChain and
reading the account after each switch.
Grants now record the wallet id and address the user approved, and
calls use exactly that wallet (older grants are bound on first use; a
missing wallet is an error, not a substitute). A switch to a chain
whose wallet has a different address is asked for. A site can no
longer "add" a built-in chain id with its own RPC and get a second
mainnet wallet; chainMeta has no chainId for built-in networks, so
that check never matched.
The HD wallet's plan listed the change output as a recipient with no
address, so a dapp payment overlay showed "To #2: undefined" and an
Amount and Total inflated by the change; the imported wallet's total
counted change and left out the fee. Plans now list payees only, with
total = payees + fee.
A site's memo went on-chain as OP_RETURN data without appearing on the
overlay, and could ride on a silent allowance payment. It is now a row,
and a payment carrying data always asks. A 32-byte-hash cashaddr was
forced into a 20-byte template, producing an unspendable script; it is
refused.
Non-Permit typed data was flagged only when its primaryType was on a
short list (Seaport, SafeTx, ...); any other order, relay or
account-abstraction type got a plain Sign button and no PIN. Every
signed field is now listed with its declared type, and a payload that
names an address other than the user's and the verifying contract
together with an amount, or carries raw bytes, gets the danger button
and the PIN; text-only payloads stay ordinary.
The encoder signed "false" as true, turned non-hex characters into zero
bytes, wrapped integers past their width (2^256+5 signed as 5) and
signed a non-array as an empty array, each while the overlay showed
the original value. Such input is now refused before any overlay.
Domain rows and the chain check use only the fields EIP712Domain
declares; others are labelled as not signed.
Solana: a ComputeBudget price the site added was paid on top of the
base fee but shown as "program ComputeB...: not decoded", so a
transaction could burn the balance in priority fees behind a plain
Sign button. The maximum network fee is now a row, and Assign, durable
nonces, Approve/ApproveChecked, SetAuthority, closing a token account
to someone else and very high fees get a warning and the danger button.
signAndSend also requires one signature slot per required signer.
Ethereum: only allowances of 2^255 and up counted as unlimited; 2^96
and up now does, and Permit2 approve, increaseApproval, NFT
safeTransferFrom and multicall are decoded. The estimate shown was
gas x the node's price even when the site set a far higher tip, which
is what is actually paid; it now uses base fee + the real tip (or the
full legacy gasPrice) and warns when the site's fee is far above the
network's or a fifth of the balance. A personal_sign over 32 raw bytes
is a hash a Safe or an order book will take as approval of something
unseen, so it gets the danger button and the PIN.
Tron: TRC10 sent along with a contract call (call_token_value) was
never read, contract types Aegis does not decode were shown by name as
if harmless, a truncated TRC20 call rendered as "undefined", and the
validity window was hidden. Those are now shown, flagged or refused;
the TronGrid draft check also refuses a memo, a permission id or an
expiration more than a day away.
Remember-me sealed the master password with whatever safeStorage
offered, which on Linux without a keyring is a constant key, i.e. the
password in the clear in the add-on store; and it stored any string
without checking it. It now uses the same keystore test as the PIN,
verifies the password with the vault first, and drops a blob sealed
under no real keystore. Settings says that remember-me leaves the
master password readable to anything running as the user, which the
PIN's TPM protection does not change.
Coin selection, change and the fee on the overlay came from the
Electrum server's listunspent values, and a legacy signature does not
commit to the value it spends. A server that under-reported a P2PKH
coin made Aegis sign away the difference as fee: a 1,000,000 sat coin
reported as 100,000 produced a transaction paying 901,180 sat while
the overlay said 1,180. Segwit v0 commits only to its own input, which
leaves the two-request variant open.
Every non-taproot input's previous transaction is now fetched, its txid
recomputed, and its output's value and script compared with the plan;
the fee of the finalized PSBT must equal the approved one.
mountWallet zeroed the vault root after mounting, but the WizardConnect
adapter kept a reference to that same buffer and read it again for
every new pairing's relay key. Every pairing made after mount therefore
got a Nostr identity derived from 32 zero bytes and the pairing URI
alone, so anyone who saw the URI (the QR, a script on the dapp page)
could read the relay traffic, xpubs included, and speak as the wallet.
The adapter now gets, and keeps, its own copy.
The signing overlay and the PIN request named the dapp by its own
userPrompt, so a dapp paired once could present itself as any site.
The pairing origin the host verified is now recorded per URI and shown
instead; the dapp's text is a quoted row with invisible and bidi
characters removed. One sign request per connection may be on screen
at a time, and revoking a site in Aegis ends its pairings too.
Theseus's own quick-unlock PIN had the same limit as Aegis's: once the
DPAPI seal is opened (as the user, or from a disk image plus the
Windows password) the 6-digit PIN falls to an offline search. The PIN
is now also the authorization value of a Platform Crypto Provider TPM
key whose secret is mixed into the wrapping key, so the chip's lockout
bounds guessing; lib/tpm-pin.cjs is the same module Aegis uses.
set() now refuses when there is no real OS keystore (Linux basic_text
included) instead of writing the blob in the clear, an unsealed record
from an older build is deleted, and Settings says what the PIN actually
protects against on this machine.
A 6-digit PIN behind PBKDF2 + DPAPI falls in minutes to anything that
can open DPAPI (malware running as the user, a disk image plus the
Windows password). The PIN is now also the authorization value of a
TPM key from the Microsoft Platform Crypto Provider; the key releases a
secret mixed with PBKDF2(pin), so the stored blob alone opens nothing
and the chip's own lockout (32 failures, then one per 10 minutes)
limits guesses however the blob was obtained. Reached through Windows
PowerShell's CNG classes with the PIN on stdin, so no native module.
Machines without a TPM keep the software PIN, and Settings now says
plainly what that protects against.
A PIN is no longer stored when there is no real OS keystore (including
Linux's basic_text backend, whose key is a constant); a pre-0.31 plain
blob is sealed or deleted and remembered, so the panel can tell the
user to change the master password if the profile was ever copied.
0.31 moved the PIN check into index.js and, with it, replaced the
15-minute lockout after five wrong PINs by "PIN off until the master
password", with new wording on every PIN screen. The user wants the
screens as they were. The wording, the lockout and the switch to the
master password are back; the check stays in index.js, so the lockout is
now enforced by the host and the panel still never sees the PIN blob.
After the lockout every further wrong PIN locks it again.
Built-in extensions can answer paths under a name they ship with, so
Pithos opens at pithos.sia/<user>/<drive>/<folder> instead of a
loopback address, signed in only while the vault is unlocked. A tab
playing sound is no longer frozen in the background, so music keeps
playing when you switch tabs. A widened left panel covers the page
instead of squeezing it and the right sidebar. The tab that was open
at close loads on launch. Bundles Pithos 0.3.10 (views, compact sidebar
layout, music player, updater footer, Account page) and Aegis 0.31.0
(the audit fixes).
Pithos needs its full-page app at pithos.sia/<user>/<drive>/<folder>
instead of a loopback address. A new site-route capability lets a
built-in add-on declare names in its manifest (siteRoutes) and register
a handler for them; the bns:// handler asks it first for every path but
the root, and a handler that returns nothing hands the request back to
the name's own site. Community add-ons cannot use it, since answering
for a name is impersonating it.
Restore left every tab dormant, the active one included, so a fresh launch
showed the right tab highlighted over an empty page until it was clicked,
which reads as a broken restore. The active tab now loads as soon as the
toolbar has painted; every other restored tab stays dormant until it is
activated, so launch still costs one page renderer.
PIN checked by the host with a hard five-guess limit, permits described
from what is signed, unreadable WizardConnect requests refused, PIN
clearances bound to their request, permissions re-read after the PIN
wait, and the Tron duplicate-field fix (also shipped alone as 0.30.1).
- send, sendToken and consolidate now require the wallet id the panel
reviewed them for; a missing id used to skip the check, and the Solana
token send sent none, so a selection change under the PIN pad sent from
another wallet.
- "1 0" was read as 10: a space inside an amount is now refused.
- A BCMR registry list saved before https was enforced is filtered on read,
and names lose the soft hyphen, Arabic letter mark, Mongolian vowel
separator, line/paragraph separators and Unicode tag characters too.
signAndSend took a permissions snapshot, waited up to two minutes for the
PIN, then wrote the snapshot back. Revoking the site meanwhile still let
the allowance payment go out and restored the revoked allowance, and any
other permission change made during the wait was lost. After the wait it
now re-reads permissions, refuses an allowance payment whose allowance was
revoked, replaced or no longer covers it, and changes only this origin's
sendTx.
Freezing background tabs stopped music and talks the moment you
switched to another tab, which no other browser does. A tab that is
audible when its freeze is due is checked again later and frozen only
once it falls quiet.
One global 90 s clearance was opened by every PIN proof. Opening the
wallet or ticking a setting let the next dapp transaction from any site
through without a PIN; a dapp waiting in its poll could take the clearance
the user had just made for their own send; and with two sites waiting the
second one's request was dropped.
Each waiting dapp transaction now has an id, and only a proof naming that
id releases it; a proof given for "transaction" in the panel clears the
panel's next send only; any other proof clears nothing. The panel answers
waiting sites one at a time. Promote to HD now asks for the PIN like any
other spend instead of failing when PIN-per-transaction is on.
wc-sign accepted a flat request (tx and sourceOutputs at the top) that
buildWcApproval does not read, so any paired dapp could get a signature
after an overlay showing only the wallet, an input count and the sighash.
The signer now takes only the nested WizardConnect shape, and the overlay
refuses, without showing anything, a request whose outputs or spent
inputs it cannot decode. Total out now sums every output, not the first 8.
describePermit read td.message directly, and the EIP-712 encoder ignores
keys a type does not declare. A dapp could put a decoy allowed:false or
details:{amount:"1"} beside an unlimited permit and the overlay showed
the decoy, not risky, under the plain Sign button, with the same digest.
The overlay and the message preview are now built from the declared
fields only, the permit variant is picked from the declared type, and
dropped fields are counted on the overlay. Amounts of 2^96 units and up
are flagged as effectively unlimited, marketplace orders and Safe
transactions get the warning too, and the encoder refuses a non-hex
address instead of signing it as zero bytes.
The panel fetched the PIN blob and decrypted it itself, then reported its
own failures. The lockout therefore counted only what a well-behaved panel
chose to report, a 15-minute timer handed out five more guesses forever,
and anything able to run in the panel could take the blob and search the
million PINs offline in minutes.
The blob now never leaves index.js: pinSet builds it after checking the
master password against the vault, pinUnwrap counts each guess before
trying it, and five wrong guesses switch the PIN off until the master
password is entered. The panel keeps its PIN pads and only sends digits.
A blob from an older build (200k iterations) is re-made at 600k under a
fresh salt on the next correct PIN. Only the topmost PIN pad listens to
typed digits, so two stacked pads cannot both take one entry.
Widening an add-on's left panel shrank the open tab to a 120 px strip
and, with the right sidebar open, pushed the sidebar narrower too. Now
the widened panel lies over the tab area like a page of its own, up to
the right sidebar, which keeps its width; the tab underneath keeps its
size. Choosing a tab, opening Settings or a new tab, or using the
address bar narrows the panel back to a bar beside the page, the way a
maximized right sidebar already steps back.
0.30.0 is on the update channel and bundled in Theseus 0.3.75 with a Tron
decoder that a hostile site can use to show one transfer and sign another.
This is 0.30.0 plus that fix only; 0.31.0 is still under review.
The decoder read the first copy of a singular protobuf field; java-tron
keeps the last. Any.value is opaque bytes, so a TransferContract carrying
two recipients and two amounts hashes to the same txid either way: the
overlay showed "1 TRX to X" while the chain would move 999 TRX to
another address. It also defeated the plan-time and sign-time draft checks
against a hostile node. A repeated singular field, or a known field with
the wrong wire type, now refuses the transaction.
The decoder read the first copy of a singular protobuf field; java-tron
keeps the last. Any.value is opaque bytes, so a TransferContract carrying
two recipients and two amounts hashes to the same txid either way: the
overlay showed "1 TRX to X" while the chain would move 999 TRX to
another address. It also defeated the plan-time and sign-time draft checks
against a hostile node. A repeated singular field, or a known field with
the wrong wire type, now refuses the transaction.
- Page-inject policy. An add-on may keep { mode, origins } under the
reserved storage key "__pageInjectPolicy"; in "allowed" mode its bridge is
injected, and its page messages accepted, only on the listed origins. It
is read from the store because the decision is made synchronously at
document start and must hold while an on-demand add-on is still dormant.
A wallet injected into every page tells every page the user has one.
api.features.pageInjectPolicy lets an add-on tell an old host apart.
- vault.imports (raw imported seeds and keys, not namespaced per add-on) and
vault.lifecycle unlock/setup/lock (the master password, and an
unthrottled oracle for it) are for add-ons that ship with Theseus. A
community extension with vault-derive could read the wallet's imported
keys. Others use vault.requestUnlock, where Theseus draws the prompt.
Still 0.31.0 (unpublished batch).
- ETH nonce. The pending count from a load-balanced RPC often misses a
transaction this wallet sent seconds ago, so two sends in a row shared a
nonce and the second failed or replaced the first. The nonce is taken at
signing, from the RPC or from what the wallet itself last broadcast,
whichever is higher, and broadcasts are serialised per wallet.
- Chains without EIP-1559 (no baseFeePerGas) get a legacy EIP-155
transaction; they rejected the type-2 envelope, so a network added by a
dapp could receive but never send. The tip is clamped to the fee cap.
- Bitcoin and DigiByte take their fee rate from the Electrum server's
estimate instead of a constant, size each output from its real script
(a taproot destination was undercounted), and round the size up before
pricing. Bitcoin inputs signal replace-by-fee. DigiByte keeps its 20
sat/vB floor and does not signal RBF, which it does not have.
- Electrum: a wallet with live subscriptions went quiet for good when its
server dropped. The client reconnects with backoff, pings to catch dead
sockets, times out a silent connect, and hands the replayed subscription
answers on as notifications so the wallet refreshes. dispose() ends it.
- Max with an SPL token selected did nothing; it now fills the exact token
balance.
- Removing a wallet retired its derivation index for good. Add wallet now
takes the lowest free index, so the same wallet comes back.
- Solana: registered through the Wallet Standard as "Aegis" instead of
setting isPhantom, with silent connect for already-connected sites.
- "Only show the wallet to sites I enable": Aegis keeps the host's
page-inject allow-list in step with enabled and connected sites.
- `absorbs` lets one add-on derive under another's vault namespace. A
community install has the field stripped, but an update of one is placed
as shipped, so a second version could declare absorbs:["aegis"] and derive
the wallet's keys. It is now honoured only for ids that ship inside
Theseus, at the one place that matters: vault.derive.
- The legacy ids Aegis absorbs ("bchwallet", "siawallet") no longer have a
bundled folder, so nothing stopped a catalog extension from installing
under one and deriving `bchwallet/...`. They are reserved at install and
refused at derive.
- A page-to-add-on message is accepted only from the tab's top-level frame.
The origin shown to the user is the top-level URL, so a subframe that
reached the channel would have been credited with its parent's origin.
- The approval overlay ignores everything but Cancel for the first 800 ms.
A page can raise it without a gesture and knows where the primary button
lands, which made "double-click here" a way to approve a spend.
PIN
- The PIN blob wraps the vault master password under six digits and sat in
plain add-on storage, so a copy of the profile reduced the master password
to a million offline PBKDF2 guesses. It is sealed with the OS keystore
before it is stored; a plain blob from an older build is sealed on first
read. New blobs use 600k iterations.
- "Ask for PIN on every transaction" was decided by the host and enforced
by nobody: `send` never checked it and dapp transactions had no PIN step.
A gate is now cleared only by the master password the PIN unwraps,
verified against the vault, which also opens a single-use transaction
clearance. Panel sends consume one; dapp transactions ask the open panel
and wait.
Spending and signing
- Consolidate emptied wallets on the panel's confirmation alone, defaulted
to every sibling when no list was sent, and swept into whatever was
selected at click time. It now needs explicit sources and the previewed
destination, and shows the whole batch on the host overlay.
- Typed data for a chain other than the connected one is refused. Permit
and Permit2 signatures name the spender, tokens, amounts and expiry, and
an unlimited one gets the danger action. Previews are no longer cut at
600/400 characters without saying so.
- eth.rpc relayed any eth_* call for sites that never connected.
- The WizardConnect overlay lists the tokens being spent and received.
Send form
- "0,5" was read as 5: the parser deleted commas. Amounts are parsed
exactly; a decimal comma is a decimal, ambiguous or non-numeric input is
refused, extra decimals are an error instead of being dropped.
- Send submits the request the summary was computed for, never a fresh read
of the form, and stays off while a plan is pending or stale.
- Switching wallet resets the form instead of leaving a live button on the
previous wallet's plan.
- The unlock field kept the master password after unlocking; the PIN pad
kept its digits and kept counting keystrokes typed elsewhere as PIN
attempts; an idle lock left a revealed key or seed form on screen.
- Enter confirmed a dialog even with focus on Cancel.
Start of the next batch; 0.30.0 is published.
- Tron panel sends signed whatever /wallet/createtransaction returned while
the approval showed the local request. The returned bytes are now decoded
and must be one transfer from this wallet, to that address, for that
amount, with a matching txID - checked at plan time and again at signing.
- Importing a Solana wallet from a seed phrase threw on every attempt (a
mis-parenthesised `new require("crypto").createHmac` plus a bare require
of an ESM-only subpath). SLIP-0010 now uses Node's HMAC, as chain-sol does.
- Imported BCH wallets put token-bearing UTXOs into coin selection. They are
excluded, as in the HD wallet, and the balance counts what can be spent.
- WizardConnect dropped the token from each spent output before signing, so
under SIGHASH_UTXOS every signature of a token transaction was invalid.
- A wallet disposed while a refresh was in flight re-armed its poll timer.
- BCMR registry content is bounded before it reaches the panel: control and
bidi characters stripped, lengths capped, decimals 0-18, icons https/ipfs
only, registries https only.
Aegis is getting a standalone forge repo (silentmode/aegis) split from
this folder. Inside Theseus it was covered by the root LICENSE; on its
own it needs the license and a description in the folder itself.
The dropdown mixed supported and unsupported languages in one list ranked
by global speakers, so the six languages the translator actually handles
today (en, es, fr, de, el, ru) were scattered between greyed rows. The
user had to scan every entry to find the ones they could pick. The
dropdown is now partitioned into two <optgroup>s — "Supported today"
first with the enabled languages, "Translator coming later (Accept-
Language only)" below with the greyed rows — so the top of the list
is actionable and the roadmap is still visible underneath.