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.
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.
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.
`smirk-admin migrate-legacy --source-url <pg> [--commit] [--limit N]` copies
user identity from a legacy v0.2.x backend database into this v0.3 DB, so users
keep their handle (and NIP-05 name@domain) when they move to a v0.3 client.
- Identity-only, joined on the derivation-independent seed_fingerprint; also
carries pubkey_hash (v3 wallets reclaim their row with no rotation step),
username, nostr_pubkey, wallet_birthday and chain restore heights. Socials/
tips/keys/wallets are intentionally not carried (no v0.3 home, or re-registered
by the client on first unlock).
- pubkey_hash + seed_fingerprint are peppered at rest, so the importer builds its
target with the real SEED_FINGERPRINT_PEPPER (run with the v0.3 .env).
- Idempotent (skips users already present by either peppered identity column),
dry-run unless --commit, best-effort per row: a transient error or a concurrent
native onboarding is reconciled, never aborting the pass. Reserved/taken/
malformed usernames and already-linked npubs are dropped; the user still lands.
- Exports validate_username so the importer applies the identical format +
reserved-name rules the HTTP handler does.
- docs/operations/LEGACY_MIGRATION.md + scripts/test-legacy-migration.sh, a
self-contained E2E that seeds alice/bob/carol plus edge cases and asserts
import, peppering, collision handling, and idempotency.
Reviewed adversarially (correctness / security / operational) before commit.
The spec carried no security scheme, so generated clients/docs could not tell
which endpoints require a session token — e.g. /wallet/utxo/balance read as
public while its handler enforces auth via extract_user_id_from_token. Add a
`bearer_auth` HTTP/JWT scheme through a Modify addon and annotate every route
that resolves a session; auth-bootstrap, public username/key lookups, and
system routes stay open. Regenerated openapi.json (drift clean).
- api/website.rs: sign-in-with-wallet (dapp connect). challenge bound to origin
+ short TTL; verify is single-use (remove-before-verify, race-safe), expiry-
checked, signature via Result<(),AppError> verifiers (no oracle), resolves an
EXISTING user by the proven key's pubkey_hash (never mints identity), mints a
Platform::Web session.
- api/users.rs: username set/get + by-username lookup (JWT-gated, 409 on taken,
constant-shape lookup); per-asset key registration bound to the JWT user
(no spoofable user_id) + public key-directory reads.
- api/nip05.rs: /.well-known/nostr.json directory (username -> nostr_pubkey).
- Wired into api/mod.rs; website+users nested at /api/v1, nip05 at root; all
paths/schemas registered (openapi.json now 17 endpoints).
- main.rs: periodic GC of expired web challenges (bounds the in-memory store).
Adversarially reviewed (register_key authz + website identity-binding both
hold; no critical/high). 69 tests green. Deferred LOW: per-IP rate-limit on the
website routes lands with the router-assembly unit.