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.
This commit is contained in:
2026-09-17 19:13:39 -04:00
parent 309da0073b
commit 7592034546
2 changed files with 233 additions and 22 deletions
+18
View File
@@ -3885,6 +3885,17 @@
"type": "boolean",
"description": "Whether a user owns this username."
},
"tip_keys": {
"oneOf": [
{
"type": "null"
},
{
"$ref": "#/components/schemas/PublicKeysInfo",
"description": "Per-asset key a sender encrypts a TARGETED tip to, if registered.\n\nSeparate from `public_keys` because for XMR and WOW the two differ: the\npublic key that identifies a Cryptonote wallet is its spend key, and\nencrypting to that is not an option (see `key_type_for_tip`). An asset\nwhose owner has published no usable target is `null` here, which the\nsender reads as \"targeted tip unavailable\", never as \"fall back to the\nidentity key\"."
}
]
},
"user_id": {
"type": [
"string",
@@ -4611,6 +4622,13 @@
"type": "string",
"description": "Asset the key is for (`btc`, `ltc`, `xmr`, `wow`, `grin`)."
},
"key_type": {
"type": [
"string",
"null"
],
"description": "Which row this key belongs in (`primary`, `enc`, `slatepack`).\n\nOmitted by every shipped wallet, and by anything registering the key a\nsignature is verified against. Required for a key whose role cannot be\nread off its own value: an `enc` key is 32 opaque bytes that look exactly\nlike a `primary` one, so without this it would silently overwrite the\nidentity key on `(user_id, asset, key_type)`."
},
"public_key": {
"type": "string",
"description": "The public key (format depends on the asset)."