- 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)
191 lines
No EOL
15 KiB
Markdown
191 lines
No EOL
15 KiB
Markdown
# 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_at−120s, 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` установлен; кросс-сборка отдельно. |