Commit Graph
9 Commits
Author SHA1 Message Date
such-gitea 7592034546 feat(keys): give an encryption subkey its own row
A targeted XMR or WOW tip has to be sealed to a key that is not the spend
key. That scalar is the spend authority and an ed25519 signing oracle the
dapp bridge already exposes, and as a raw reduced scalar it does not pair
with age's SHA512(seed)[0..32] derivation anyway.

Grin got its second row for free, because a slatepack address is bech32 and
a signing key is hex, so the value alone says which row it belongs in. An
encryption subkey has no such tell: it is 32 opaque bytes that look exactly
like the identity key, so it needs an explicit key_type or the upsert on
(user_id, asset, key_type) overwrites the key sign-in verifies against.

So RegisterKeyRequest gains an optional key_type. Omitted, every shipped
wallet keeps the value-based routing it relies on today. Supplied, it must
name one of primary, enc or slatepack; anything else is a 400 rather than a
row nothing will ever read. One invariant outranks the request: a bech32
Grin address is still refused from the primary row, which is the outage
ddbba30 fixed.

The lookup response gains tip_keys alongside public_keys, applying the
per-asset target server-side. For BTC, LTC and Grin the two maps agree. For
XMR and WOW tip_keys carries the enc row or nothing at all, so a wallet too
old to have published one has no tip target, which is the right answer
rather than a fallback to the spend key.
2026-09-17 19:13:39 -04:00
such-gitea 309da0073b feat(migration): adopt a legacy identity on proven sign-in
Sign-in resolves the proven key against this instance first, then the BTC
pubkey_hash fallback. When both miss and the legacy directory is configured,
it now asks whether the legacy instance knows that key, and adopts the
identity if so. With the directory off, which is the default, the lookup
resolves nothing and the refusal is exactly what it was.

The import creates a NEW row rather than attaching to an existing one, and
that is the point. Fourteen of the sixteen legacy handles are also held here
by rows we cannot prove belong to the same person, so adopting by name could
hand this person's keys to someone else's account. The keys just proven are
the identity; a handle is carried over only when it is free here, and a key
another account already holds is skipped so one key can never resolve to two
users.

pubkey_hash is not carried either: legacy computes a plain sha256 where this
instance stores an HMAC, so the value would be meaningless here, and a
collision would be with a row we cannot identify.

Migrating on sign-in rather than in a batch means every import is gated on a
signature the user just produced, and the population drains on its own as
people arrive. Nothing is copied for a user who never returns.

Not yet exercised against a real legacy database: that needs the read-only
credential in place, so this stays closed until it is.
2026-09-15 10:35:53 -04:00
such-gitea 468d3f0add feat(migration): read-only legacy directory, off unless configured
The two deployments never shared a database. The legacy instance holds 134
identities whose keys v3 has never seen, so pointing the website at v3 would
refuse all of them: nothing in smirk_v3_db can match a key that exists only
in smirk_db.

Neither obvious join key survives contact with the data. users.pubkey_hash
is a plain sha256 on legacy and an HMAC under identity_pepper on v3, so the
columns are not comparable at all. Usernames are actively dangerous: 14 of
the 16 handles exist in both databases while only 6 users share a raw key,
so merging by name would hand one person's keys to someone else's account.

What is reliable is the public key a signature just proved control of. This
looks up that key and nothing else, so it can only answer about a key the
caller could already sign for.

The capability is closed by default. With no LEGACY_DATABASE_URL nothing
connects, every lookup answers "not found", and sign-in behaves exactly as
it does now. A URL that points nowhere degrades to disabled rather than
failing the boot, because a migration convenience must never be able to stop
the service from serving. Nothing calls it yet.
2026-09-15 10:33:24 -04:00
such-gitea ddbba30765 fix(grin): keep the slatepack address from overwriting the signing key
A v0.3.0 wallet registers two different Grin keys in sequence. Bootstrap
stores the Smirk-derived public key, which is what /auth/website/verify
checks a Grin signature against, and a deferred effect then registers the
canonical grin-wallet slatepack address. These are separate derivations,
not two encodings of one key, and both wrote (user_id, 'grin', 'primary').
The second silently replaced the first, so signing in to the website with
Grin could not match any account.

Route the value to its own row instead. A slatepack address is bech32 under
the grin/tgrin HRP and a hex public key cannot start with those letters,
since none of them are hex digits, so the two are separable by construction
rather than by guess. That matters because it fixes wallets that are already
shipped: they keep posting what they post today and land in the right row.

lookup_username picked with an unqualified find on the asset, which returns
an arbitrary row once an asset has two. It now selects by key_type, and for
Grin prefers the slatepack address, falling back to a primary row only while
that row still holds a pre-split address. A sender is never handed a public
key where an address belongs.

The migration is additive: existing bech32 values are copied into the new
row and the primary row is left for the next unlock to rewrite. Nothing is
deleted, so a user who never unlocks again keeps a working address.
2026-09-14 23:03:13 -04:00
such-gitea 985a05adf8 fix(website): resolve sign-in by the asset that signed, not only the BTC hash
website_verify resolved every asset through get_user_by_pubkey_hash, but
users.pubkey_hash only ever holds the BTC key's hash. On this backend only BTC
could ever resolve; ltc, xmr, wow and grin were structurally impossible, and the
failure was indistinguishable from an unregistered user, so the page told people
to register with an extension they already had.

That did not surface while smirk.cash pointed at the legacy backend, which
resolves by asset key against its own database. It becomes the whole bug the
moment the site is repointed here, so it has to land with that change, not after.

get_user_by_pubkey_hash stays as the BTC fallback: of 95 users on this backend,
84 arrived through migrate-legacy, which imported identity without keys, and
would otherwise stop resolving entirely.

The rejection message is unchanged and still says nothing about whether an
account exists.
2026-09-14 22:07:20 -04:00
such-gitea 1d7e1d5a78 fix(grin): serve the height endpoint through grin-lws like the rest
/wallet/grin/height called grin_client directly, skipping the grin_tip helper
declared immediately above it, which prefers grin-lws and falls back to
grin-wallet. It was the only Grin endpoint that could not be served by grin-lws
alone.

With GRIN_OWNER_API_URL empty, as it is in production, this failed while scan and
broadcast worked, so Grin read as half broken rather than as one path being
unwired. Scan already prefers grin-lws and broadcast goes to the node's foreign
API, so nothing else depended on the wallet.
2026-09-12 19:32:14 -04:00
such-gitea d04d1c789d fix(nip98): widen the clock window and say what actually failed
The register, login and link handlers rejected any proof signed more than 30
seconds from server time, measured against a clock the client owns and we do
not. Ordinary desktop drift exceeds that, so a user at a new location failed
every attempt with 'Invalid Nostr proof': a cryptographic-sounding message that
names nothing the user can act on.

The window is now 120 seconds and symmetric, so a fast clock is bounded like a
slow one, and the message points at the device clock. Erasure moves with it:
left at 30 it would have let a device register and log in while silently unable
to delete or export its own data, which is harder to diagnose than a uniform
failure.

120 seconds is a mitigation, not a cure. Nobody has measured the reported
machine's offset, and whole-hour errors from a wrong timezone or an RTC kept in
local time are common. The durable fix is to return the server's time with the
challenge so an honest client stamps from it; the client already accepts an
injected timestamp.
2026-09-09 11:00:31 -04:00
such-gitea d48936308e fix(lws): count unconfirmed change as pending instead of erasing it
sum_mempool_received computed received minus sent, which reads as a net figure
but double-counts the spend: every mempool row's spent_outputs already go to the
wallet, which subtracts them after verifying each key image.

On a send with change, sent exceeds received because the whole input is consumed
and part comes back, so the subtraction underflowed and saturated to zero. The
change output then appeared nowhere until it confirmed. Reported 2026-09-08:
sending 0.3 from a 7 XMR output left 6.7 XMR invisible for a block, which to the
person holding the wallet looks like money that has gone missing.

Pure receives were unaffected, their sent being zero, which is why the mempool
work looked complete when it was tested against incoming payments.

The existing test encoded the defect as intended behaviour, annotating a
change-shaped row as 'net 0 via saturating_sub'. Corrected, plus a regression
case for the reported amounts and a property test that a spend can never shrink
the pending total.
2026-09-08 09:55:49 -04:00
such-gitea afa3ec7679 fix(lws): treat an existing account as registered, not as a node outage
register_account called add_account unconditionally when provisioning was off,
which is production's default. monero-lws answers a duplicate with
account_exists as an HTTP 500, the backend maps that to a node error, and the
user is told NODE_UNAVAILABLE. A routine re-registration was therefore
indistinguishable from the node being down.

Reported 2026-09-08: a tip failed with an upstream error while both LWS
instances were active and had logged no warnings in 24 hours. Nothing was wrong
with the node or the account.

The provisioning path already checked existence first and documented why. That
check now guards both paths, so registration is idempotent and the two branches
cannot drift apart again.
2026-09-08 09:44:23 -04:00