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

15 KiB
Raw Permalink Blame History

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.

Статус

  • Изучен сервер-репо, протокол, API, векторы (source review + read-only smoke GET: healthz/readyz/config ok, claims без токена → честный 401). Видимых багов сервера нет.
  • OPENCODE.md создан.
  • Скаффолд Cargo (feature gui = iced; cargo test --no-default-features = быстрое ядро без iced).
  • 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 чист.
  • 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 чист.
  • 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_mut().read_json()/read_to_string(), http_status_as_error(false) в конфиге агента).
  • inbox/verify. Готово: src/inbox.rsInbox (в памяти): 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).
  • notify + autostart. Готово: src/notify.rsApprovalNotification{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).
  • 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_requestsInbox::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 установлен; кросс-сборка отдельно.