niko_trust_gui/OPENCODE.md
Niko Marmeladkov 6278321873
niko_trust_gui: TCE protocol client with iced GUI
- Byte-exact TCE codec (encoder/decoder, canonical numbers, vectors-tested)
- Ed25519 identities with bech32m trust1… addresses
- Relay client: challenge/auth handshake, request feed, responses,
  transparent re-auth on 401
- iced GUI (Material-dark, Solana-style palette): tab shell, approval
  prompts with native toasts on Linux, address book, history, settings,
  password change
- Portable single-file vault (identity + book + history + settings)
- Live 2FA roundtrip examples (send_2fa, await_2fa)
2026-08-22 23:20:41 +03:00

191 lines
No EOL
15 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# OPENCODE — контекст проекта Niko Trust GUI
Этот файл — постоянная память контекста. Обновлять при каждом значимом решении/изменении,
чтобы не терять суть между сессиями.
## Что это
**Niko Trust GUI** — лёгкий GUI-клиент (Rust, **iced**) к trust-релею
(`~/niko_trust`, Go, `git.n1ko.dev/Niko/niko_trust`). Один бинарник, Linux + Windows.
Для людей, которые не разбираются в криптографии.
**Product-флоу (история юзера):**
1. Юзер запускает GUI.
2. Первый запуск: создание identity (ed25519), установка пароля, согласие на автозагрузку.
3. Юзер копирует свой адрес (`trust1q...`) и вставляет его в нужную программу/сервис
(например, на свою почту как 2FA).
4. При входе в сервис сервис создаёт `ApprovalRequest` на этот адрес.
5. GUI периодически опрашивает релей и при новом запросе шлёт **нативную нотификацию
с кнопками Approve/Deny**.
6. Юзер жмёт кнопку → GUI подписывает `ApprovalResponse` и кладёт его в релей.
7. Сервис видит ответ (`GET /v1/responses?request=<id>`) и пускает/не пускает юзера.
## Ключевые пути/ресурсы
| Что | Где |
|---|---|
| GUI-репо (этот) | `/home/niko/niko_trust_gui` |
| Сервер-репо (Go) | `/home/niko/niko_trust` |
| Протокол (нормативный) | `~/niko_trust/docs/PROTOCOL.md` |
| HTTP API | `~/niko_trust/docs/API.md` |
| Trust-модель | `~/niko_trust/docs/TRUST-MODEL.md` |
| Адреса | `~/niko_trust/docs/ADDRESS.md` |
| Frozen test vectors (эталон) | `~/niko_trust/testdata/vectors/tce_vectors.json` |
| Go-эталон кодирования | `~/niko_trust/internal/protocol/encode.go`, `internal/tce/encoder.go` |
## Серверы
- **Production:** `https://trust.n1ko.dev`
- **Тест сейчас:** `http://192.168.31.155:5743` — значение по умолчанию в конфиге GUI.
Настройка переключает на production-пресет.
- **`audience` для подписей не хардкодим** — берём из `GET /v1/config` (`{"audience":"..."}`).
- Инструкция юзера: **если найден баг в сервере — остановиться и сообщить.**
## Ключевые решения и почему
1. **GUI = iced** (выбрано юзером, а не egui). Elm-style async (Subscription),
нативный вид, хорошо для poll-потока. Минус: больше кода.
2. **Seed хранится зашифрованным паролем**: `argon2id` (KDF) →
`XChaCha20-Poly1305` (AEAD), файл `0600`. Юзер вводит пароль при каждом запуске.
Безопасность важна: seed подписывает Approval.
3. **Windows-уведомления с кнопками** через `winrt-notification`;
**fallback** (если COM/регистрация нестабильны) — toast без кнопок + попап
в приложении с Approve/Deny. Linux — `notify-rust` (libnotify), кнопки из коробки.
4. **HTTP = `ureq` + rustls** — лёгкий, без openssl, один бинарник, blocking в фоновом
потоке под iced `Subscription`.
5. **Верификация подписей — на клиенте.** `PUT /v1/objects` НЕ проверяет подписи
(INV-5: релей отвечает "кто сказал", не "верить ли"). Значит GUI обязан:
строго декодировать, валидировать pubkey, проверить ed25519 по встроенному в TCE ключу,
применить правила объекта (скев ±120 с, lifetime запроса ≤60 с).
6. **TCE — byte-exact.** Ноль нормализации строк, canonical uvarint, сортировка карт по
сырым байтам, decimal-числа без float. Проверка — сверка с frozen vectors.
7. **Только клиент держит приватный ключ** (server никогда не импортит signer; INV-1).
У нас то же правило: no server-side signing.
## Rust-эквиваленты Go
| Go | Rust |
|---|---|
| `crypto/ed25519` (RFC8032, детерм.) | `ed25519-dalek` |
| `filippo.io/edwards25519` (ValidatePubKey) | `curve25519-dalek` |
| `btcutil/bech32` (bech32m) | crate `bech32` (Bech32m) |
| `crypto/sha256` (object_id) | `sha2` |
| `crypto/rand` (nonce/seed) | `rand` + `getrandom` |
| argon2id + XChaCha20-Poly1305 | crates `argon2`, `chacha20poly1305` |
| HTTP | `ureq` (rustls) |
## Wire-протокол (сводка, детали в PROTOCOL.md)
- Magic 21 байт `trust.n1ko.dev/tce/1\0`, затем object_tag (1 байт), version uvarint (=1).
- Теги: 0x01 Identity, 0x02 Claim, 0x03 Revocation, 0x04 ApprovalRequest,
0x05 ApprovalResponse, 0x06 AuthAssertion.
- `object_id = hex(sha256(tce))`. Подпись = Ed25519 над точными TCE-байтами (не над JSON).
- Identity-поле: `uvarint(address_version=0) || uvarint(32) || pubkey(32)``00 20 <32>`.
- Строки: `uvarint(len) || utf8`, без контроля, без нормализации, без BOM.
- uvarint canonical (без `81 00`). Таймстампы: uvarint, диапазон 1e9..4102444800,
`0` разрешён только как "no expiry".
- Значения: 0x00 null, 0x01 false, 0x02 true, 0x03 string, 0x04 number(canonical decimal text).
- Карты: `uvarint(count) || (enc_bytes(key)||value)*`, ключи по `[a-z][a-z0-9]*([._-][a-z0-9]+)*`,
сортировка bytewise ascending, дубли запрещены.
- Адрес: bech32m, hrp `trust`, payload = `0x00||pubkey33`, строгий decode (lowercase, no pad bits).
- Полные лимиты полей и объектов — PROTOCOL.md §6.3 (зашиты в encoder/decoder).
### Поля объектов (строгий порядок!)
- **IdentityRegistration (0x01):** identity, alias(≤64), created_at
- **Claim (0x02):** issuer, subject, claims(map≥1), created_at, expires_at(0=noexp), serial, nonce(16)
- **Revocation (0x03):** issuer, claim_id(32), reason(≤256), created_at, nonce(16)
- **ApprovalRequest (0x04):** sender, recipient, action(≤128), payload(map,может быть пустой),
message(≤256), created_at, expires_at(≤ created_at+60s!), nonce(16)
- **ApprovalResponse (0x05):** request_hash(32), responder, decision(uvarint 0|1), created_at, nonce(16)
- **AuthAssertion (0x06):** identity, challenge(32), scope(≤32), audience(≤128), created_at
### API-эндпоинты (детали API.md)
- `POST /v1/auth/challenge``{"challenge":"<hex>"}` (rate 30/мин/IP)
- `POST /v1/auth/assert` (Envelope AuthAssertion) → `{"session_token","identity","scope"}`
- `POST /v1/objects` (Envelope) → `{"object_id"}`; idempotent; 1 response/request (422 иначе)
- `GET /v1/objects/{id}`, `GET /v1/claims?subject=`, `GET /v1/requests?recipient=`,
`GET /v1/responses?request=`, `GET /v1/revocations?claim=` + `limit`/`offset`
- Read-эндпоинты: `Authorization: Bearer <token>` или `?token=`; сессия 30 мин.
- Scope для чтения всего: `"read"` или `"*"` (или точный `read:requests` и т.д.); 401/403.
- `GET /v1/config` (audience), `/v1/healthz`, `/v1/readyz`, `/v1/metrics`.
### Auth-поток клиента
1. `GET /v1/config` → audience.
2. `POST /v1/auth/challenge` → 32-байт challenge (hex).
3. Собрать `AuthAssertion{identity=mypub, challenge, scope:"*", audience, created_at=now}`.
4. Подписать TCE, `POST /v1/auth/assert` с Envelope `{tce, signature}``session_token`.
5. Дальше read-эндпоинты с Bearer. Сессия живёт 30 мин; при 401 → пере-auth.
### Ответ на запрос
1. GET `/v1/requests?recipient=<мой адрес>&limit=N`. Каждый item = Envelope `{tce, signature}` + object view.
2. Строго декодировать ApprovalRequest из `tce`; извлечь sender, action, message, nonce, created_at, expires_at;
вычислить request_hash = sha256(tce).
3. Проверить подпись: address.ValidatePubKey(sender) + ed25519.Verify(sender, tce, signature).
4. Проверить время: now ∈ [created_at120s, expires_at+120s].
5. Показать: message + sender-address + действие. NOT как endorsement от релея.
6. Approve → `ApprovalResponse{request_hash, responder=mypub, decision=1, created_at=now, nonce=rand16}`
→ подписать → `POST /v1/objects`. Deny → decision=0.
7. Один ответ на запрос — сервер отклонит второй (422).
## Тестовые векторы (эталон)
- Партии: NikoCraft seed=`01`×32, Niko seed=`02`×32 (указы в vectors: pubkey, address).
- Проверки: encoder даёт тот же hex; object_id совпадает; dalek verify подписи ok;
decoder→re-encode тождество; адреса совпадают; таблица canonical numbers;
выборочные rejects на decoder.
## Статус
- [x] Изучен сервер-репо, протокол, API, векторы (source review + read-only smoke GET:
healthz/readyz/config ok, claims без токена → честный 401). Видимых багов сервера нет.
- [x] OPENCODE.md создан.
- [x] Скаффолд Cargo (feature `gui` = iced; `cargo test --no-default-features` = быстрое ядро без iced).
- [x] tce (encoder/decoder/number) + address (свой bech32m) + signer (ed25519-dalek 3, is_weak+verify_strict)
+ protocol (6 объектов). **Все frozen vectors byte-exact: encode→hex, object_id, подпись (dalek
воспроизводит детерменированные подписи), decode→re-encode, reject-набор, числа, адреса.** clippy чист.
- [x] keyring (argon2id + XChaCha20-Poly1305, файл 0600). **Готово**: `src/keyring.rs`,
`FileFormat{v,kdf,m,t,p,salt,nonce,cipher}` (base64), `aead` добавлен в deps (0.6),
`XNonce::from`, 5 unit-тестов (roundtrip, wrong-password, kdf-params), clippy чист.
- [x] relay + live-тест. **Готово**: `src/relay.rs` + `tests/live_relay.rs` (env `NIKO_RELAY`).
Auth-рукопожатие (config→audience, challenge, AuthAssertion{scope:"*"}, assert→Bearer),
`fetch_requests` (строгая декодировка + validate_pubkey + verify + recipient must = me,
пропускает ядовитые конверты), `respond` (проверка окна запроса ±120с, request_hash=sha256(tce),
POST objects), `store`. **3 live-теста против `192.168.31.155:5743` проходят:**
roundtrip (ответ принят, 2-й ответ на тот же запрос → 422), wrong-recipient отфильтрован,
forged-подпись отброшена. ВАЖНО: у ureq 3.4 API другой (http::Response<Body> +
body_mut().read_json()/read_to_string(), `http_status_as_error(false)` в конфиге агента).
- [x] inbox/verify. **Готово**: `src/inbox.rs``Inbox` (в памяти): dedup по request_id
(каждый запрос всплывает один раз), история `HistoryItem{request_id_hex, sender(Address),
action, message, created_at, expires_at, outcome}`, `in_window(created,expires,now)` с
±120s skew (§13.1), outcome Pending/Approved/Denied/TimedOut/Stale. `observe()` возвращает
только actionable (`Pending`); истёкшие/из будущего записываются в историю как TimedOut/Stale.
`Address` получил `PartialEq, Eq`. 3 unit-теста (dedup, window+outcomes, outcome_changes).
- [x] notify + autostart. **Готово**: `src/notify.rs``ApprovalNotification{sender,action,message}`,
`show()` (notify-rust, кнопки Approve/Deny, ждёт через отдельный поток), `PromptHandle::wait_timeout`,
`PromptOutcome{Approved,Denied,Ignored}`, `to_decision()`, `interactive_supported()` = `dbus_stack().is_some()`.
`src/autostart.rs` — обёртка `auto-launch` (XDG autostart на Linux, registry на Windows), `APP_NAME="niko-trust"`,
`enable/disable/is_enabled`. Smoke-тест `examples/smoke.rs` отработал на этой машине: dbus есть,
уведомление показывается (кнопки кликнуть некому — демона нет, outcome=Ignored после таймаута), autostart is_enabled=false.
Модули `notify`/`autostart` вне feature-gate `gui` (не зависят от iced).
- [x] UI iced (последний блок). **Готово**: `src/main.rs` (модуль `gui`, feature `gui`; вне gui — стаб
`main` с ошибкой). Сборка: iced 0.14 + фича `tokio` (default-бэкенд `thread-pool` имеет пустой
`time`, без tokio/smol нет `iced::time::every`). Экраны: lock (первый запуск = создание
identity, иначе разблокировка; поле relay URL, default `https://trust.n1ko.dev`) и main
(заголовок с адресом/релеем, статус-строка, карточка запроса с Approve/Deny, история).
Poll-loop: `Subscription` `time::every(5s)``Relay::fetch_requests``Inbox::observe`
очередь (`VecDeque`) → текущий `Prompt`. Нативные нотификации: `notify::show` в отдельном
потоке, outcome в UI через `futures::channel::mpsc` + глобальный слот `PROMPT_RX` +
`Subscription::run_with(request_id, |_| iced::stream::channel(...))` (инфраструктура
`iced::stream`/`futures` реэкспортируется iced'ом). Ответ: `Relay::respond` в `Task::perform`,
при истечении окна (проверка `in_window`) помечается TimedOut. **Изменено в ядре**: в
`IncomingRequest` добавлено поле `tce: Vec<u8>` (точные байты запроса нужны для request_hash
при ответе; re-encode не byte-exact). Проверено: `cargo build`+`clippy --all-targets` чистые
с gui, и `--no-default-features`), 10 lib + 3 tce + 3 live тестов зелёные, **live-тесты
прогнаны против production `NIKO_RELAY=https://trust.n1ko.dev` — проходят**, GUI запускается
(без падений, первый запуск = экран создания).
## Команды
- `cargo build` / `cargo test` / `cargo clippy` в `/home/niko/niko_trust_gui`.
- Live-интеграционные тесты: env `NIKO_RELAY=https://trust.n1ko.dev` (или `http://192.168.31.155:5743`).
- Windows: target `x86_64-pc-windows-gnu` установлен; кросс-сборка отдельно.