```
NKIP: 3
Title: Sign-In with Monero
Status: Draft
Type: Standards Track
Created: 2026-10-27
License: CC0-1.0
Requires: RFC 3986, RFC 8259
```

# NKIP-0003: Sign-In with Monero

A site proves that a user controls a Monero address by asking the user's
wallet to sign a short, fixed-format message. No funds move. It is the Monero
counterpart of Sign-In with Ethereum (EIP-4361), built on the message signatures
every Monero wallet already makes (`sign` / `verify`).

## Motivation

Sites that identify users by a Monero address (payout profiles, creator pages,
paid APIs) need a login that doesn't depend on an email address, a phone
number or a third-party account. Today each site invents its own
"sign this text" format, and wallets can't show the user in a consistent way
what they are approving.

Goals:

- **G1** A verifier can check, with nothing but a public Monero address, that
  its holder approved signing in to one named site, once, within a few minutes.
- **G2** The wallet builds the text it signs from named fields, so it can show
  the user exactly what the signature means, and a signature for one site
  can't be used at another.
- **G3** Any wallet that can make Monero message signatures can take part, by
  deep link, QR code or copy-paste.
- **G4** The signature hands the site a key of the site's own choosing (a
  device key), so the site never needs the wallet again until the next
  sign-in.

Non-goals:

- Payments (see the XMR402 protocol, which proves control by paying).
- Linking a Monero address to a real-world identity.
- Session management after sign-in; that is the site's own business.

## Roles

| Role | Does |
| --- | --- |
| **Site** (relying party) | The web page the user signs in to. Generates the device key pair, asks the verifier for a challenge, opens the challenge URI, waits for the result. |
| **Verifier** | The site's backend (it may run on another host). Issues challenges, stores their state, checks signatures, binds the device key on success. |
| **Wallet** | Holds the address's keys. Parses the challenge, shows it to the user, signs on explicit approval, posts the signature to the callback. |

## Specification

The key words MUST, SHOULD and MAY are used as in RFC 2119.

### Challenge URI

```
monero-signin:?address=<ADDRESS>&nonce=<NONCE>&session=<SESSION>&origin=<HOST>
  &purpose=<PURPOSE>&handle=<HANDLE>&dk=<DK>&issued_at=<TIME>&expires_at=<TIME>
  &return_url=<CALLBACK>&v=1
```

The query is `application/x-www-form-urlencoded`; values are percent-decoded
before any check, and `+` decodes to a space. Verifiers MUST percent-encode
every value they put in the URI (so a literal `+` travels as `%2B`). A wallet MAY
also accept the same query under its own URI scheme.

| Parameter | Required | Rule |
| --- | --- | --- |
| `address` | MUST | Mainnet standard address or subaddress (95 characters, starts with `4` or `8`). Integrated addresses are refused. |
| `nonce` | MUST | Verifier-chosen, at least 128 bits of randomness, 22–128 characters of `[A-Za-z0-9_-]` (22 base64url characters carry 128 bits; a hex nonce MUST be at least 32, which wallets can't tell apart, so this is the verifier's duty; wallets refuse anything else, since they can't check the randomness). |
| `session` | MUST | Verifier-chosen opaque id of this challenge, 1–128 characters of `[A-Za-z0-9_-]`; echoed in the callback. Not part of the message. |
| `origin` | MUST | Bare host of the site the user is signing in to, with an optional port: `[a-z0-9.-]+(:port)?`. No scheme, path or user info. Internationalized names travel as `xn--` (punycode), and wallets show them in that form, never converted to Unicode. |
| `purpose` | SHOULD | Short word for what the sign-in is for: 1–32 characters of `[a-z0-9_-]`. When it is absent, wallet and verifier both use `login`, and the message says `Purpose: login`. |
| `handle` | MUST | The account name at the site the user is signing in as: 1–64 Unicode code points, none of them in a Unicode Other (`C*`: control, format, surrogate, private use, unassigned) or Separator (`Z*`: spaces, line and paragraph separators) category, none with the Default_Ignorable_Code_Point property (this covers variation selectors), and none of the blank-rendering letters U+115F, U+1160, U+3164, U+FFA0 or U+2800. The value MUST already be in Unicode NFC form, and verifiers look accounts up by the NFC handle. This rules out right-to-left overrides (U+202A–U+202E, U+2066–U+2069), zero-width characters (U+200B–U+200D) and spaces. |
| `dk` | MUST | Device-key fingerprint: the first 16 lowercase hex characters of SHA-256 over the device public key **as the verifier stores it** (for example the 64-character lowercase hex of an Ed25519 public key). |
| `issued_at` | MUST | UTC time, `YYYY-MM-DDTHH:MM:SS[.sss]Z`. |
| `expires_at` | MUST | Same format. `issued_at < expires_at ≤ issued_at + 15 minutes`. |
| `return_url` | MUST | The verifier's callback, generated by the verifier (never by the page or the wallet). `https:`; `http:` only to `localhost`, `127.0.0.1` or `[::1]`. No user name, password or `#` fragment. |
| `v` | MUST | `1` for this version. |

A wallet MUST refuse the whole challenge, and sign nothing, when:

- a required parameter is missing or empty, or any parameter appears twice;
- any value contains a control character (U+0000–U+001F, U+007F), since a
  line break would add lines to the signed message, or, in `handle` and
  `purpose`, any character outside their rules above, since invisible or
  direction-changing characters would make the wallet show something other
  than what is signed;
- `origin`, `dk`, `address`, the times or `return_url` break the rules above;
- `v` is not a version it implements (there is no fallback signing);
- the query carries a parameter this version doesn't define. v1 is strict;
  a later version that adds parameters changes `v`.

Verifiers SHOULD keep the whole URI short enough for a QR code (under about
600 characters), mainly by keeping `return_url` short.

### Canonical message (v1)

The wallet builds the message itself from the parameters and signs exactly
these UTF-8 bytes: lines joined by a single LF (`0x0A`), **no trailing
newline**. It never signs a text supplied by the site.

```
<origin> wants you to sign in with your Monero address:
<address>

Sign in to <origin> as @<handle>. This proves you control this address. No funds move.

Purpose: <purpose>
Handle: <handle>
Nonce: <nonce>
Device-Key: <dk>
Issued-At: <issued_at>
Expiration-Time: <expires_at>
Version: 1
```

`Device-Key` is in the message so the signature names the one device key it
authorizes: a verifier whose stored state was altered after signing can't bind
a different key, and a wallet can show the user that the key came from the page
they are on when it knows that page. The verifier stores the full key; the
64-bit fingerprint only has to rule out collisions with keys the attacker
doesn't control.

`session` and `return_url` are deliberately **not** in the message. The
callback is authenticated by the verifier's own state, not by the signature:
the signature covers the nonce, and the verifier maps that nonce to exactly
one stored challenge with one device key (see Verifier rules). Wherever the
signature is delivered, it can only complete that one challenge.

A future version changes the `Version:` line, so signatures of different
versions never collide.

### Signature

A Monero message signature (`SigV1…` or `SigV2…`) over the message bytes, by
the **spend key** of `address` (for a subaddress, its derived spend key), as
`monero-wallet-rpc` `sign` makes with `signature_type: spend`.

- Wallets SHOULD produce `SigV2` (domain-separated and bound to the address's
  spend and view keys). `SigV1` is accepted for existing wallets and is
  deprecated.
- Verifiers MUST accept both, and MUST accept only spend-mode signatures. A
  view-mode signature proves only that someone holds the view key, so it is
  refused. `monero-wallet-rpc` `verify` reports `good: true` for view-mode
  signatures too, so a verifier using it MUST require both `good: true` and
  `signature_type: "spend"`. A verifier whose `verify` doesn't report the type
  MUST check the signature against the address's public spend key directly,
  as `tools/nkip0003.py` does.
- The wallet signs with the keys of exactly the address in the URI. With
  `monero-wallet-rpc` `sign` that means passing the `account_index` and
  `address_index` of that address (looked up with `get_address_index`).

### Callback

The wallet sends:

```
POST <return_url>
Content-Type: application/json

{"session": "<session>", "address": "<address>", "signature": "<SigV…>"}
```

The verifier answers `200 {"ok": true}` on success, or a 4xx with
`{"ok": false, "error": "<text>"}`. The error text, status code, size and
timing MUST NOT reveal which check failed. Web wallets post across origins, so
the callback MUST answer CORS preflight requests allowing `POST` with
`Content-Type: application/json` from any origin; the challenge state, not the
caller's origin, authenticates the request. How the site learns the result (polling, push) is up to the site and
the verifier.

### Verifier rules

A verifier MUST:

1. Bind the challenge to an account it can check. The authenticated principal
   is the pair (account, address): the verifier MUST refuse to issue a
   challenge whose `address` isn't the address the account named by `handle`
   holds, and MUST check this again at callback time. (An unclaimed handle MAY
   be created by its first successful sign-in; that is registration, and the
   site decides whether it allows it.) This is what stops an attacker from
   asking for a challenge as `@victim` with their own address.
2. Generate `nonce`, `session`, both times and `return_url` itself, and store
   them with `address`, `origin`, `handle`, `purpose` and the full device
   public key, before returning the challenge URI. It stores `issued_at` and
   `expires_at` as the exact strings it put in the URI and rebuilds the message
   from those strings, never by re-formatting a timestamp.
3. Enforce expiry with its own clock, with a lifetime of at most 15 minutes
   (3 minutes RECOMMENDED). The wallet's time checks are only a convenience.
4. Accept each challenge at most once: a successful callback consumes it.
   Failed callbacks don't, so someone who only saw the URI (over a shoulder,
   in a log) can't cancel the user's sign-in with a bad signature. The
   verifier rate-limits callbacks per challenge and per source instead, and
   deletes challenge state when it expires.
5. Rebuild the message from the stored state, never from the callback body,
   and require the callback's `address` to equal the stored one.
6. Verify the signature as above, with the stored `address`.
7. Recompute `dk` from the stored device key and refuse if it differs from the signed `dk`. On success, bind exactly the stored device key (the one `dk` was computed
   from) to the account, and nothing else.

A verifier SHOULD NOT log nonces, signatures or full challenge URIs, and
SHOULD rate-limit challenge requests. It SHOULD run on the
`origin` site or a subdomain of it. Another host is allowed, and the wallet
shows it.

### Wallet rules

A wallet MUST:

1. Refuse invalid challenges (see Challenge URI).
2. Show before signing: `origin`, `handle`, the address (or which of the
   user's accounts it belongs to), the callback host, whether that host is
   the origin's own site, the expiry time and the full message. "Own site"
   means the callback host and `origin` have the same registrable domain under
   the Public Suffix List (so `a.github.io` and `b.github.io` are different
   sites); ports are ignored.
3. Tell the user to approve only a sign-in they started themselves, just now,
   on the site whose address is in their browser's address bar (see
   Security considerations).
4. Require an explicit approval for each signature; never sign automatically.
5. Refuse when it doesn't hold the spend key for `address`.
6. When it knows which page opened the challenge (a browser-extension or web
   wallet), refuse unless that page's host has the same registrable domain as
   `origin`. This closes relay phishing for those wallets; a separate wallet app
   that only receives a link or QR code can't do this check.

A wallet SHOULD refuse a challenge whose `expires_at` has passed or whose
`issued_at` is more than 5 minutes in the future by its own clock.

### Flow

```
Site                          Verifier                         Wallet
 | 1. make device key pair       |                                |
 | 2. request challenge -------->| store state, build URI         |
 |<------------- challenge URI --|                                |
 | 3. open URI (link or QR) ---------------------------------------->|
 |                               |       4. parse, show, approve,  |
 |                               |          build message, sign    |
 |                               |<---------- 5. POST return_url --|
 |                               | 6. rebuild, verify, consume,    |
 |                               |    bind device key              |
 | 7. learn result (poll/push) ->|                                |
 | 8. signed in with device key  |                                |
```

## Requirements

| ID | Level | Goal | Requirement | Acceptance check |
| --- | --- | --- | --- | --- |
| R1 | MUST | G2 | The wallet builds the v1 message byte-for-byte from the parameters; no trailing newline | Test vectors: message and SHA-256 |
| R2 | MUST | G2 | The wallet refuses challenges with control characters, invisible or direction-changing characters in `handle`/`purpose`, a non-NFC `handle`, an unknown parameter, a nonce outside `[A-Za-z0-9_-]`, repeated or missing parameters, bad `origin`/`dk`/`address`/times/callback, or an unknown `v` | Test vectors: invalid challenges |
| R3 | MUST | G1 | Verifiers accept SigV1 and SigV2, spend mode only (`good` and `signature_type: spend`, or a direct spend-key check) | Test vectors: valid signatures and a view-mode signature |
| R4 | MUST | G1 | Verifiers rebuild the message from stored state (exact time strings), enforce expiry with their own clock, and accept each challenge once; failed callbacks don't consume it | Code review; replay test |
| R11 | MUST | G1 | Verifiers issue and complete challenges only when `address` is the address the `handle`'s account holds (or the handle is unclaimed and the site allows registration) | Code review; takeover test |
| R5 | MUST | G4 | On success the verifier binds exactly the stored device key | Code review |
| R6 | MUST | G2 | The wallet shows origin, handle, address, callback host (flagged when not the origin's own site), expiry and the full message, plus the approve-only-if-you-started-it warning | UI review |
| R7 | MUST | G3 | Challenges work as a deep link, a QR code and pasted text with the same query | Manual |
| R8 | SHOULD | G1 | Wallets produce SigV2 | Code review |
| R10 | MUST | G2 | A wallet that knows the opening page refuses when its registrable domain differs from `origin` | Extension test |
| R9 | MUST before leaving Draft | — | Schema, test vectors and a checker in CI | CI on this repo |

## Security considerations

| Threat | Likelihood | Control |
| --- | --- | --- |
| **Relay phishing**: an attacker starts a real challenge for the victim's account with the attacker's device key and gets the victim to approve it, e.g. from a page that looks like the site | High | **The protocol can't prevent this**: the message is honest (right site, right account) and the attacker can use the real callback. Controls: the wallet's warning (R6: approve only a sign-in you started, on the site in your address bar); short lifetimes; sites SHOULD notify the account owner of every new sign-in and show and let them revoke device keys. `dk` does not help here, since a user can't tell whose key a fingerprint belongs to |
| Account takeover: an attacker requests a challenge as `@victim` with their own address and signs it | High | R11: the verifier refuses a challenge whose address isn't the account's, at challenge and callback time |
| Signature sent to a callback on another host | Medium | Harmless by construction: the signature covers the nonce, which completes only its own challenge. If the attacker made that challenge they could have used the real callback; if the victim made it, it binds only the victim's own device key. The wallet still shows and flags the host |
| Replay of an old signature | Medium | Single-use challenge, verifier clock, ≤ 15 min lifetime (R4) |
| Cross-site replay | Medium | `origin` is in the message; a verifier for another site rebuilds a different message |
| Lines injected into the message (`%0A` in a value) | Medium | Control characters refused (R2), so every field stays on its own line |
| Display spoofing: a right-to-left override or zero-width character makes the wallet show a different handle or site than the signed bytes | Medium | `handle` and `purpose` character rules (R2); `origin` is ASCII and shown as punycode |
| Someone who saw the URI cancels the sign-in with a bad callback | Low | Failed callbacks don't consume the challenge (R4); per-challenge rate limits |
| Verifier trusts the callback body | Medium | Message rebuilt from stored state only (R4) |
| View-key holder signs in (watch-only wallet, auditor) | Medium | Spend-mode signatures only (R3) |
| Wrong wallet clock | Low | The verifier's clock decides (R4); the wallet's checks only catch obvious mistakes |
| SigV1 lacks domain separation (the same hash scheme as other Monero signatures over arbitrary data) | Low | The message is fixed-format text starting with the origin, never a hash; SigV2 recommended (R8) |
| Callback over plain HTTP | Low | `https:` only, except loopback for local testing |

**Privacy.** A sign-in reveals the address to the site, which already knows
it. The wallet's callback reveals the wallet's IP address to the verifier;
wallets MAY send it over Tor or another proxy. Nothing is published.

## Open questions

- [ ] Register `monero-signin:` (or a shorter scheme) as a provisional URI
  scheme with IANA.
- [ ] Multiple device keys per account (adding a key without removing the
  previous one) is left to sites; should v2 carry a `Device-Label` line?
- [ ] v2: put the full SHA-256 of the device key (or the key itself) in the message instead of a 64-bit fingerprint.
- [ ] Machine sign-in for agents (no human approval) needs its own key-custody
  rules; out of scope for v1.

## Test vectors

`nkip-0003/vectors/v1.json`, checked by `python3 tools/nkip0003.py check
nkips/nkip-0003/vectors/v1.json` (needs `pycryptodome`). They contain:

- two valid challenges (a primary address with the callback on another site;
  a subaddress with the callback on a subdomain), with the exact message, its
  SHA-256, the device key behind `dk`, a SigV2 from `monero-wallet-rpc`
  v0.18.4.5 `sign`, and a SigV1, all checked with its `verify`;
- twenty-five invalid challenges with the reason each is refused (including a right-to-left override and a zero-width space in `handle`, a `+` in `nonce`, an uppercase `purpose`, an unknown parameter, a non-NFC handle and a Hangul filler in `handle`);
- two signatures that must fail: a view-mode signature, and a primary-address
  signature checked against the subaddress.

The addresses belong to a throwaway offline test wallet whose private keys are
in the file. Never send funds to them. Signatures are randomized, so
implementations check that these verify, not that they reproduce them.

## Implementations

Add yours with a pull request. Projects by this NKIP's authors are marked.

| Project | Role | Status |
| --- | --- | --- |
| kyc.rip "Sign in with Ripley" (authors' project) | Verifier | v1 message byte-identical. Not yet conforming to R4: failed callbacks still consume the challenge |
| Ripley Terminal (authors' project) | Wallet | Builds and signs the v1 message |

## Changelog

- 2026-10-27: Review round 2 (OpenCode 8.3, pass): Default_Ignorable and blank-rendering characters refused in `handle`, verifier-side hex-nonce minimum, explicit `dk` recompute, R2 completed.
- 2026-10-27: Review round 1 (OpenCode 7.7): account binding as a MUST (R11), explicit `signature_type: spend` check, `get_address_index` for `sign`, nonce at least 22 characters, strict unknown-parameter refusal, NFC handles, CORS on the callback, no leaking which check failed, failures never consume a challenge, `Device-Key` rationale, QR length.
- 2026-10-27: Review fixes: `handle`/`purpose`/`nonce` character rules (no invisible or direction-changing characters), punycode display, `+` encoding, exact time strings, explicit `purpose` default, failed callbacks no longer consume a challenge until the fifth, same-site by registrable domain, page-origin check for wallets that know the opening page (R10).
- 2026-10-27: Draft. Message format and parameters from the existing "sign in
  with a Monero wallet" deployment (unchanged bytes, so it conforms), with the
  rules that deployment left implicit made explicit: every parameter required,
  control characters, repeated parameters, short nonces and callbacks with credentials or fragments refused, unknown versions
  refused, spend-mode signatures only, verifier-side expiry and single use,
  and the relay-phishing limit stated instead of claimed solved. Old links
  without `return_url` or `v` are not valid challenges.
