docs: QR-LOGIN reverse-mode standard

- docs/QR-LOGIN.md: canonical QR JSON contract (v/type/relay/svc/code/msg),
  reverse flow, client and service rules, security model (server can't
  substitute, code is a match+anti-phishing marker not an authenticator)
- SERVICE-GUIDE.md: link the two login modes
This commit is contained in:
Niko Marmeladkov 2026-08-26 17:51:04 +03:00
parent 3bf13fa488
commit 1b6a1984c0
2 changed files with 123 additions and 0 deletions

115
docs/QR-LOGIN.md Normal file
View file

@ -0,0 +1,115 @@
# QR-LOGIN — Код-подтверждение входа по QR
Стандарт на «reverse»-подтверждение: **телефон сканирует QR и сам отправляет
подтверждение** сервису, вместо того чтобы сервис заранее знал адрес
пользователя. Работает и для входа, и для регистрации, и не требует
изменений в протоколе TCE — используется обычный объект `ApprovalRequest`.
Этот документ задаёт единый формат QR и правила взаимодействия: его обязаны
понимать (а) клиент-кошелёк (Android), который печатает QR на камеру и (б)
сервис, который QR показывает.
## Формат QR
QR кодирует компактный JSON (намеренно без пробелов):
```json
{"v":1,"type":"nt-login","relay":"https://trust.n1ko.dev","svc":"trust1qz...","code":"482913","msg":"Вход в Forgejo"}
```
| Поле | Обязательно | Тип | Смысл |
|-------|-------------|--------------------|---------------------------------------------------------------|
| `v` | да | целое = `1` | версия формата; клиент отвергает незнакомое |
| `type`| да | строка = `nt-login`| тип QR; клиент игнорирует остальные |
| `relay`| да | URL | база релея (http/https/wss), куда класть подтверждение |
| `svc` | да | адрес `trust1...` | адрес сервиса, которому адресовано подтверждение (получатель) |
| `code`| да | ровно 6 цифр | одноразовый код для сверки (см. ниже) |
| `msg` | нет | строка ≤ 64 | человекочитаемое имя запроса, напр. «Вход в Forgejo» |
Сервис, показывающий QR, сам генерирует `code` (случайно) и показывает его
рядом на экране крупно — это код анти-фишинга, а НЕ аутентификатор.
## Поток
```
браузер/сервис телефон (клиент) релей
| показывает QR | |
| {relay, svc, code, msg} | |
|-------------------------------->| скан + показ диалога |
| | «msg? сервис short_svc» |
| | код 482913 (сверка) |
| пользователь жмёт Подтвердить | |
| | mint ApprovalRequest{ |
| | sender: я, |
| | recipient: svc, |
| | action: "login", |
| | payload:{code}, |
| | ttl ≤ 60s } |
| |-- store ------------------->|
| poll /v1/requests?recipient=svc| |
| <- envelope {tce,sig} ---------| |
| VerifyApprovalRequest | |
| match payload.code + свежесть | |
| user = req.sender -> сессия | |
```
Суть «reverse»-режима: подписанный `ApprovalRequest` от **пользователя** является
подтверждением личности. Сервису не нужно заранее знать адрес — он получает его
из проверенной подписи отправителя.
## Правила для клиента (кошелька)
1. Валидация QR: `v == 1`, `type == "nt-login"`, `relay` — валидный URL,
`svc` — валидный адрес `trust1...` (каноническая проверка public key),
`code` — ровно 6 цифр, `msg`, если есть, ≤ 64 символов. Любое нарушение —
отказать с объяснением.
2. Диалог подтверждения до отправки:
- заголовок = `msg` (если есть, иначе «Вход в сервис»);
- обязательно показать **короткий адрес сервиса** рядом с `msg` — имя
самозаявлено, ему не доверяют (см. INV-7, алиасы неавторитетны);
- крупно показать `code` для визуальной сверки с экраном браузера.
3. Отправить один `ApprovalRequest`:
- `sender` = ключ пользователя (подпись ставит клиент);
- `recipient` = `svc`;
- `action` = `"login"`;
- `payload` = `{"code": "<code из QR>"}` (код попадает внутрь подписи);
- `created_at` = now, `expires_at` = now + ≤60 (ограничение формата TCE);
- записать в историю.
4. Не показывать `code` в истории постоянно; пометить статусом «отправлено».
## Правила для сервиса
1. Сгенерировать одноразовый `code` и показывать QR вместе с ним.
2. Опрашивать `/v1/requests?recipient=<свой адрес>&after=<курсор>`
(или подписаться на канал `requests` по WS) со своей сессией чтения.
3. Для каждого конверта: `VerifyApprovalRequest` → проверить свежесть окна
(в границах `created_at..expires_at` за 60 с) → сопоставить
`payload.code` со своим активным QR.
4. Совпадение по `code` + прошедшая подпись ⇒ личность = `req.sender`.
Выпустить собственную сессию для этого адреса.
5. (Опционально) закрыть петлю: подписать `ApprovalResponse{Allow}` на этот
request и сохранить — клиент увидит «approved» в истории.
## Безопасность
- **Релей не может подставить адрес.** Подпись отправляет только владелец
ключа; релей видит всё, но подписать ничьим именем не может (максимум —
цензура/задержка, что неотличимо от отказа в обслуживании).
- **`code` — не аутентификатор**, а метка для выборки и визуальной сверки.
Он зашит внутрь подписанного payload, поэтому его нельзя вырвать из
подлинного запроса и подставить в поддельный.
- **Анти-фишинг:** если злоумышленник показывает на сайте чужой QR, код на
телефоне не совпадёт с кодом на экране браузера — заметно сразу.
- **Replay/одноразовость:** `code` живёт не дольше окна запроса (≤ 60 с);
сервис принимает каждый code один раз.
- **`msg` самозаявлена** и показывается только рядом с верифицированным
адресом; она нужна для узнавания, а не для доверия.
## Совместимые SDK
Готовые реализации чтения/отправки и серверной верификации:
- **Android-кошелёк**: `:sdk` (niko_trust_android) — парсер QR + `sendLogin`.
- **Server/Go**: `pkg/protocol.VerifyApprovalRequest`, пример поллинга —
`examples/service`.
- **Rust**: клиентская библиотека `niko_trust_gui` (модули `protocol`/`relay`).

View file

@ -20,6 +20,14 @@
ключом пользователя и привязан к хешу запроса), а компрометация релея не даёт
атакующему ничего, кроме отказа в обслуживании.
Есть два способа подтверждения:
- **Адресный** (ниже): сервис уже знает `trust1...` пользователя и шлёт запрос
на его кошелёк.
- **QR / reverse** (без знания адреса): пользователь сканирует QR телефона и
сам отправляет подтверждение сервису. Подходит и для входа, и для
регистрации. Формат и правила — в [QR-LOGIN.md](QR-LOGIN.md).
## Поток логина
```