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)
This commit is contained in:
commit
6278321873
27 changed files with 11213 additions and 0 deletions
13
.gitignore
vendored
Normal file
13
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
# Rust / Cargo
|
||||
/target
|
||||
**/*.rs.bk
|
||||
|
||||
# Local builds & artifacts
|
||||
*.exe
|
||||
/dist
|
||||
|
||||
# Editor / OS noise
|
||||
.idea/
|
||||
.vscode/
|
||||
*.swp
|
||||
.DS_Store
|
||||
5290
Cargo.lock
generated
Normal file
5290
Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load diff
56
Cargo.toml
Normal file
56
Cargo.toml
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
[package]
|
||||
name = "niko_trust_gui"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
description = "Niko Trust GUI client (trust.n1ko.dev)"
|
||||
license = "MIT"
|
||||
|
||||
[features]
|
||||
default = ["gui"]
|
||||
# "gui" builds the iced desktop app. Disable (--no-default-features) for a fast
|
||||
# core-only build that skips the heavy GUI dependency tree.
|
||||
gui = ["dep:iced"]
|
||||
|
||||
[dependencies]
|
||||
# crypto
|
||||
sha2 = "0.11"
|
||||
ed25519-dalek = { version = "3.0", default-features = true }
|
||||
curve25519-dalek = "5.0"
|
||||
chacha20poly1305 = "0.11"
|
||||
aead = "0.6"
|
||||
argon2 = "0.5"
|
||||
subtle = "2"
|
||||
zeroize = { version = "1", features = ["zeroize_derive"] }
|
||||
getrandom = "0.4"
|
||||
|
||||
# codecs / io
|
||||
bech32 = "0.12"
|
||||
hex = "0.4"
|
||||
base64 = "0.23"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
dirs = "6"
|
||||
ureq = { version = "3.4", default-features = true, features = ["json"] }
|
||||
|
||||
# GUI (cross-platform)
|
||||
# "tokio" gives iced::time::every (the default thread-pool backend has an empty
|
||||
# time module), so the poll loop has a real timer.
|
||||
iced = { version = "0.14", optional = true, features = ["tokio"] }
|
||||
|
||||
# autostart (cross-platform: XDG autostart / Windows registry)
|
||||
auto-launch = "0.6"
|
||||
|
||||
[target.'cfg(target_os = "linux")'.dependencies]
|
||||
notify-rust = "4.18"
|
||||
|
||||
[target.'cfg(target_os = "windows")'.dependencies]
|
||||
winrt-notification = "0.5"
|
||||
windows = "0.62"
|
||||
|
||||
[dev-dependencies]
|
||||
# nothing yet
|
||||
|
||||
[profile.release]
|
||||
lto = "thin"
|
||||
strip = true
|
||||
codegen-units = 1
|
||||
191
OPENCODE.md
Normal file
191
OPENCODE.md
Normal file
|
|
@ -0,0 +1,191 @@
|
|||
# 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` установлен; кросс-сборка отдельно.
|
||||
103
examples/await_2fa.rs
Normal file
103
examples/await_2fa.rs
Normal file
|
|
@ -0,0 +1,103 @@
|
|||
//! Full 2FA service flow: post an approval request, then poll for the user's
|
||||
//! decision until the request window lapses.
|
||||
//!
|
||||
//! Usage:
|
||||
//! cargo run --example await_2fa -- [relay] <recipient-address> [action] [message]
|
||||
//!
|
||||
//! Exit code: 0 = approved, 4 = denied, 5 = timeout.
|
||||
|
||||
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
|
||||
|
||||
use niko_trust_gui::address::Address;
|
||||
use niko_trust_gui::protocol::{encode_approval_request, ApprovalRequest};
|
||||
use niko_trust_gui::relay::{object_hash, request_id_hex, Relay};
|
||||
use niko_trust_gui::signer::Signer;
|
||||
use niko_trust_gui::tce::{MAX_APPROVAL_LIFETIME, NONCE_SIZE};
|
||||
|
||||
fn main() {
|
||||
let mut args = std::env::args().skip(1);
|
||||
let first = args.next().unwrap_or_default();
|
||||
let (base, addr_s) = if first.starts_with("trust1") {
|
||||
("https://trust.n1ko.dev".to_string(), first)
|
||||
} else {
|
||||
let a = args.next().unwrap_or_else(|| {
|
||||
eprintln!("usage: await_2fa [relay] <recipient-address> [action] [message]");
|
||||
std::process::exit(2);
|
||||
});
|
||||
(first.trim_end_matches('/').to_string(), a)
|
||||
};
|
||||
let action = args.next().unwrap_or_else(|| "2fa".to_string());
|
||||
let message = args
|
||||
.next()
|
||||
.unwrap_or_else(|| "Confirm sign-in (2FA)".to_string());
|
||||
|
||||
let recipient = Address::parse(&addr_s).expect("invalid recipient address");
|
||||
let sender = Signer::generate().expect("generate sender key");
|
||||
|
||||
let created = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.expect("clock")
|
||||
.as_secs();
|
||||
let mut nonce = [0u8; NONCE_SIZE];
|
||||
getrandom::fill(&mut nonce).expect("rng");
|
||||
|
||||
let req = ApprovalRequest {
|
||||
sender: sender.pubkey(),
|
||||
recipient: *recipient.pubkey(),
|
||||
action,
|
||||
payload: Default::default(),
|
||||
message,
|
||||
created_at: created,
|
||||
expires_at: created + MAX_APPROVAL_LIFETIME,
|
||||
nonce,
|
||||
};
|
||||
let tce = encode_approval_request(&req).expect("encode request");
|
||||
let sig = sender.sign(&tce);
|
||||
let req_hash = object_hash(&tce);
|
||||
|
||||
// The "service" authenticates and stores the request.
|
||||
let agent = Relay::auth(&base, &sender, "*").expect("sender auth");
|
||||
let id = agent.store(&tce, &sig).expect("store request");
|
||||
println!(
|
||||
"request {} stored (id {}), waiting up to {MAX_APPROVAL_LIFETIME}s for a decision…",
|
||||
req.action, id
|
||||
);
|
||||
|
||||
// Poll for the response until the window lapses.
|
||||
let rid = request_id_hex(&tce);
|
||||
let deadline = Instant::now() + Duration::from_secs(MAX_APPROVAL_LIFETIME + 15);
|
||||
while Instant::now() < deadline {
|
||||
std::thread::sleep(Duration::from_secs(2));
|
||||
let responses = match agent.fetch_responses(&rid) {
|
||||
Ok(r) => r,
|
||||
Err(e) => {
|
||||
eprintln!("poll error: {e}");
|
||||
continue;
|
||||
}
|
||||
};
|
||||
for inc in responses {
|
||||
if inc.response.request_hash != req_hash {
|
||||
continue; // answer to some other request
|
||||
}
|
||||
if inc.response.responder != *recipient.pubkey() {
|
||||
eprintln!(
|
||||
"warning: response from {}, expected {}",
|
||||
inc.responder, recipient
|
||||
);
|
||||
continue;
|
||||
}
|
||||
match inc.response.decision {
|
||||
niko_trust_gui::protocol::Decision::Allow => {
|
||||
println!("DECISION: APPROVED by {}", inc.responder);
|
||||
std::process::exit(0);
|
||||
}
|
||||
niko_trust_gui::protocol::Decision::Deny => {
|
||||
println!("DECISION: DENIED by {}", inc.responder);
|
||||
std::process::exit(4);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
println!("DECISION: TIMEOUT (no answer within the request window)");
|
||||
std::process::exit(5);
|
||||
}
|
||||
59
examples/send_2fa.rs
Normal file
59
examples/send_2fa.rs
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
//! Send a live approval request ("2FA authentication") to a recipient.
|
||||
//!
|
||||
//! Usage:
|
||||
//! cargo run --example send_2fa -- [relay] <recipient-address> [action] [message]
|
||||
//!
|
||||
//! Defaults: relay = https://trust.n1ko.dev, action = "2fa".
|
||||
|
||||
use niko_trust_gui::address::Address;
|
||||
use niko_trust_gui::protocol::{encode_approval_request, ApprovalRequest};
|
||||
use niko_trust_gui::relay::Relay;
|
||||
use niko_trust_gui::signer::Signer;
|
||||
use niko_trust_gui::tce::NONCE_SIZE;
|
||||
|
||||
fn main() {
|
||||
let mut args = std::env::args().skip(1);
|
||||
let first = args.next().unwrap_or_default();
|
||||
let (base, addr_s) = if first.starts_with("trust1") {
|
||||
("https://trust.n1ko.dev".to_string(), first)
|
||||
} else {
|
||||
let a = args.next().unwrap_or_else(|| {
|
||||
eprintln!("usage: send_2fa [relay] <recipient-address> [action] [message]");
|
||||
std::process::exit(2);
|
||||
});
|
||||
(first.trim_end_matches('/').to_string(), a)
|
||||
};
|
||||
let action = args.next().unwrap_or_else(|| "2fa".to_string());
|
||||
let message = args
|
||||
.next()
|
||||
.unwrap_or_else(|| "Confirm sign-in (2FA)".to_string());
|
||||
|
||||
let recipient = Address::parse(&addr_s).expect("invalid recipient address");
|
||||
|
||||
let sender = Signer::generate().expect("generate sender key");
|
||||
let created = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.expect("clock")
|
||||
.as_secs();
|
||||
|
||||
let mut nonce = [0u8; NONCE_SIZE];
|
||||
getrandom::fill(&mut nonce).expect("rng");
|
||||
|
||||
let req = ApprovalRequest {
|
||||
sender: sender.pubkey(),
|
||||
recipient: *recipient.pubkey(),
|
||||
action,
|
||||
payload: Default::default(),
|
||||
message,
|
||||
created_at: created,
|
||||
expires_at: created + niko_trust_gui::tce::MAX_APPROVAL_LIFETIME,
|
||||
nonce,
|
||||
};
|
||||
let tce = encode_approval_request(&req).expect("encode request");
|
||||
let sig = sender.sign(&tce);
|
||||
|
||||
let agent = Relay::auth(&base, &sender, "*").expect("sender auth");
|
||||
let id = agent.store(&tce, &sig).expect("store request");
|
||||
println!("request stored: object id {}", id);
|
||||
println!("recipient: {}", recipient);
|
||||
}
|
||||
32
examples/smoke.rs
Normal file
32
examples/smoke.rs
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
use std::time::Duration;
|
||||
|
||||
use niko_trust_gui::autostart::Autostart;
|
||||
use niko_trust_gui::notify::{show, ApprovalNotification};
|
||||
|
||||
fn main() {
|
||||
println!("interactive_supported = {}", niko_trust_gui::notify::interactive_supported());
|
||||
|
||||
let handle = show(ApprovalNotification {
|
||||
sender: "trust1qz9g...jya75".to_string(),
|
||||
action: "login".to_string(),
|
||||
message: "Approve 2FA login to mail.example.com".to_string(),
|
||||
});
|
||||
let outcome = handle.wait_timeout(Duration::from_secs(5));
|
||||
println!("outcome = {outcome:?}");
|
||||
|
||||
let exe = std::env::current_exe().unwrap();
|
||||
match Autostart::new(&exe) {
|
||||
Ok(a) => {
|
||||
println!(
|
||||
"autostart app={} path={:?}",
|
||||
niko_trust_gui::autostart::APP_NAME,
|
||||
exe
|
||||
);
|
||||
match a.is_enabled() {
|
||||
Ok(enabled) => println!("is_enabled = {enabled}"),
|
||||
Err(e) => println!("is_enabled error: {e}"),
|
||||
}
|
||||
}
|
||||
Err(e) => println!("autostart build failed: {e}"),
|
||||
}
|
||||
}
|
||||
311
src/address.rs
Normal file
311
src/address.rs
Normal file
|
|
@ -0,0 +1,311 @@
|
|||
//! Trust addresses: bech32m encoding of an Ed25519 public key plus a protocol
|
||||
//! version byte.
|
||||
//!
|
||||
//! hrp = "trust"; payload = version(1) || pubkey(32). bech32m (BIP-350) is used
|
||||
//! and checked for, so a bech32-checksum downgrade is rejected. Every valid
|
||||
//! address decodes to exactly one key and vice versa; public keys are
|
||||
//! validated as canonical prime-order curve points (see [validate_pubkey]).
|
||||
|
||||
// The parallel to the Go implementation is exact except that bech32m is
|
||||
// implemented here directly (~90 lines) rather than via a dependency, which
|
||||
// keeps the parser fully under our control and unambiguous.
|
||||
|
||||
use ed25519_dalek::VerifyingKey;
|
||||
|
||||
const HRP: &str = "trust";
|
||||
const VERSION0: u8 = 0x00;
|
||||
const PUBKEY_SIZE: usize = 32;
|
||||
const PAYLOAD_SIZE: usize = 1 + PUBKEY_SIZE;
|
||||
|
||||
const CHARSET: &[u8; 32] = b"qpzry9x8gf2tvdw0s3jn54khce6mua7l";
|
||||
const BECH32M_CONST: u32 = 0x2bc8_30a3;
|
||||
const BECH32_CONST: u32 = 1;
|
||||
|
||||
/// Exact canonical encoded length of a version-0 trust address (65 chars).
|
||||
pub const ENCODED_LEN: usize = 5 + 1 + 53 + 6;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum AddressError {
|
||||
/// Empty input.
|
||||
Empty,
|
||||
/// Input is longer than any address can be.
|
||||
TooLong,
|
||||
/// Input contains uppercase characters.
|
||||
NotLowercase,
|
||||
/// Bad checksum or malformed data.
|
||||
Checksum,
|
||||
/// Valid checksum but the bech32 (not bech32m) constant.
|
||||
NotBech32m,
|
||||
/// Human-readable part is not "trust".
|
||||
WrongHrp,
|
||||
/// Decoded payload is not version || 32-byte key.
|
||||
PayloadSize,
|
||||
/// Unknown protocol version byte.
|
||||
Version,
|
||||
/// Non-zero padding bits (a second spelling of an address).
|
||||
Padding,
|
||||
/// Public key is not 32 bytes.
|
||||
KeySize,
|
||||
/// Public key is not a valid curve point.
|
||||
KeyNotOnCurve,
|
||||
/// Public key encoding is non-canonical.
|
||||
KeyNonCanonical,
|
||||
/// Public key has small order.
|
||||
KeySmallOrder,
|
||||
}
|
||||
|
||||
impl std::fmt::Display for AddressError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
let s = match self {
|
||||
AddressError::Empty => "address: empty",
|
||||
AddressError::TooLong => "address: too long",
|
||||
AddressError::NotLowercase => "address: must be lowercase",
|
||||
AddressError::Checksum => "address: invalid checksum",
|
||||
AddressError::NotBech32m => "address: not bech32m",
|
||||
AddressError::WrongHrp => "address: wrong human-readable part",
|
||||
AddressError::PayloadSize => "address: wrong payload size",
|
||||
AddressError::Version => "address: unsupported version",
|
||||
AddressError::Padding => "address: non-canonical padding",
|
||||
AddressError::KeySize => "address: public key must be 32 bytes",
|
||||
AddressError::KeyNotOnCurve => "address: public key is not a valid curve point",
|
||||
AddressError::KeyNonCanonical => "address: public key encoding is non-canonical",
|
||||
AddressError::KeySmallOrder => "address: public key has small order",
|
||||
};
|
||||
f.write_str(s)
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for AddressError {}
|
||||
|
||||
/// Returns the byte index of `c` in the bech32 charset, if any.
|
||||
fn charset_pos(c: u8) -> Option<u8> {
|
||||
CHARSET.iter().position(|&x| x == c).map(|i| i as u8)
|
||||
}
|
||||
|
||||
fn polymod(values: &[u8]) -> u32 {
|
||||
let gen = [0x3b6a_57b2u32, 0x2650_8e6d, 0x1ea1_19fa, 0x3d42_33dd, 0x2a14_62b3];
|
||||
let mut chk: u32 = 1;
|
||||
for &v in values {
|
||||
let b = chk >> 25;
|
||||
chk = ((chk & 0x1ff_ffff) << 5) ^ (v as u32);
|
||||
for (i, &g) in gen.iter().enumerate() {
|
||||
if (b >> i) & 1 == 1 {
|
||||
chk ^= g;
|
||||
}
|
||||
}
|
||||
}
|
||||
chk
|
||||
}
|
||||
|
||||
fn hrp_expand(hrp: &str) -> Vec<u8> {
|
||||
let mut out = Vec::with_capacity(hrp.len() * 2 + 1);
|
||||
for &c in hrp.as_bytes() {
|
||||
out.push(c >> 5);
|
||||
}
|
||||
out.push(0);
|
||||
for &c in hrp.as_bytes() {
|
||||
out.push(c & 31);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn create_checksum(hrp: &str, data: &[u8]) -> [u8; 6] {
|
||||
let mut values = hrp_expand(hrp);
|
||||
values.extend_from_slice(data);
|
||||
values.extend_from_slice(&[0; 6]);
|
||||
let pm = polymod(&values) ^ BECH32M_CONST;
|
||||
let mut out = [0u8; 6];
|
||||
for (i, o) in out.iter_mut().enumerate() {
|
||||
*o = ((pm >> (5 * (5 - i))) & 31) as u8;
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn convert_bits(data: &[u8], from_bits: u32, to_bits: u32, pad: bool) -> Option<Vec<u8>> {
|
||||
let mut acc: u32 = 0;
|
||||
let mut bits: u32 = 0;
|
||||
let max_v: u32 = (1 << to_bits) - 1;
|
||||
let max_acc: u32 = (1 << (from_bits + to_bits - 1)) - 1;
|
||||
let mut out = Vec::with_capacity(data.len() * from_bits as usize / to_bits as usize + 1);
|
||||
for &v in data {
|
||||
if (v as u32) >> from_bits != 0 {
|
||||
return None;
|
||||
}
|
||||
acc = ((acc << from_bits) | v as u32) & max_acc;
|
||||
bits += from_bits;
|
||||
while bits >= to_bits {
|
||||
bits -= to_bits;
|
||||
out.push(((acc >> bits) & max_v) as u8);
|
||||
}
|
||||
}
|
||||
if pad {
|
||||
if bits > 0 {
|
||||
out.push(((acc << (to_bits - bits)) & max_v) as u8);
|
||||
}
|
||||
} else {
|
||||
// No leftover partial group, and no padding bits set.
|
||||
if bits >= from_bits || ((acc << (to_bits - bits)) & max_v) != 0 {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
Some(out)
|
||||
}
|
||||
|
||||
fn encode_bech32m(hrp: &str, data: &[u8]) -> String {
|
||||
let mut result = String::with_capacity(hrp.len() + 1 + data.len() + 6);
|
||||
result.push_str(hrp);
|
||||
result.push('1');
|
||||
let checksum = create_checksum(hrp, data);
|
||||
for &d in data {
|
||||
result.push(CHARSET[d as usize] as char);
|
||||
}
|
||||
for c in checksum {
|
||||
result.push(CHARSET[c as usize] as char);
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
/// Decodes a bech32 string into (hrp, 5-bit data without checksum, is_bech32m).
|
||||
fn decode_bech32(s: &str) -> Result<(String, Vec<u8>, bool), AddressError> {
|
||||
if s.is_empty() {
|
||||
return Err(AddressError::Empty);
|
||||
}
|
||||
if s.len() < 8 {
|
||||
return Err(AddressError::Checksum);
|
||||
}
|
||||
if s.len() > ENCODED_LEN {
|
||||
return Err(AddressError::TooLong);
|
||||
}
|
||||
if s.as_bytes().iter().any(|&c| c.is_ascii_uppercase()) {
|
||||
return Err(AddressError::NotLowercase);
|
||||
}
|
||||
let pos = s.rfind('1').ok_or(AddressError::Checksum)?;
|
||||
if pos < 1 || pos + 7 > s.len() {
|
||||
return Err(AddressError::Checksum);
|
||||
}
|
||||
let hrp = &s[..pos];
|
||||
let data_part = &s[pos + 1..];
|
||||
let combined: Vec<u8> = data_part
|
||||
.as_bytes()
|
||||
.iter()
|
||||
.map(|&c| charset_pos(c).ok_or(AddressError::Checksum))
|
||||
.collect::<Result<_, _>>()?;
|
||||
let pm = {
|
||||
let mut values = hrp_expand(hrp);
|
||||
values.extend_from_slice(&combined);
|
||||
polymod(&values)
|
||||
};
|
||||
if pm != BECH32M_CONST && pm != BECH32_CONST {
|
||||
return Err(AddressError::Checksum);
|
||||
}
|
||||
let is_m = pm == BECH32M_CONST;
|
||||
let n = combined.len();
|
||||
Ok((hrp.to_string(), combined[..n - 6].to_vec(), is_m))
|
||||
}
|
||||
|
||||
/// Validates a public key as a canonical, on-curve, prime-order-subgroup
|
||||
/// point — mirroring the Go reference. Without this an attacker could
|
||||
/// register an all-zero key whose zero signature verifies for every message.
|
||||
pub fn validate_pubkey(pubkey: &[u8]) -> Result<(), AddressError> {
|
||||
if pubkey.len() != PUBKEY_SIZE {
|
||||
return Err(AddressError::KeySize);
|
||||
}
|
||||
let arr: [u8; PUBKEY_SIZE] = pubkey.try_into().unwrap();
|
||||
// ZIP-215 point validation (rejects off-curve). Non-canonical encodings
|
||||
// are accepted as valid points by ZIP-215, so we check canonical by
|
||||
// re-encoding below.
|
||||
let vk = VerifyingKey::from_bytes(&arr).map_err(|_| AddressError::KeyNotOnCurve)?;
|
||||
if vk.to_bytes() != arr {
|
||||
return Err(AddressError::KeyNonCanonical);
|
||||
}
|
||||
// A small-order ("weak") key makes signatures forgeable or ambiguous.
|
||||
if vk.is_weak() {
|
||||
return Err(AddressError::KeySmallOrder);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct Address {
|
||||
s: String,
|
||||
key: [u8; PUBKEY_SIZE],
|
||||
}
|
||||
|
||||
impl Address {
|
||||
/// Encodes a validated public key as a version-0 trust address.
|
||||
pub fn from_pubkey(pubkey: &[u8]) -> Result<Address, AddressError> {
|
||||
validate_pubkey(pubkey)?;
|
||||
let arr: [u8; PUBKEY_SIZE] = pubkey.try_into().unwrap();
|
||||
let mut payload = Vec::with_capacity(PAYLOAD_SIZE);
|
||||
payload.push(VERSION0);
|
||||
payload.extend_from_slice(&arr);
|
||||
let five = convert_bits(&payload, 8, 5, true).unwrap();
|
||||
let s = encode_bech32m(HRP, &five);
|
||||
Ok(Address { s, key: arr })
|
||||
}
|
||||
|
||||
/// Strictly parses and validates a trust address.
|
||||
pub fn parse(s: &str) -> Result<Address, AddressError> {
|
||||
let (hrp, data5, is_m) = decode_bech32(s)?;
|
||||
if !is_m {
|
||||
return Err(AddressError::NotBech32m);
|
||||
}
|
||||
if hrp != HRP {
|
||||
return Err(AddressError::WrongHrp);
|
||||
}
|
||||
let payload = convert_bits(&data5, 5, 8, false).ok_or(AddressError::Padding)?;
|
||||
if payload.len() != PAYLOAD_SIZE {
|
||||
return Err(AddressError::PayloadSize);
|
||||
}
|
||||
if payload[0] != VERSION0 {
|
||||
return Err(AddressError::Version);
|
||||
}
|
||||
validate_pubkey(&payload[1..])?;
|
||||
let mut key = [0u8; PUBKEY_SIZE];
|
||||
key.copy_from_slice(&payload[1..]);
|
||||
Ok(Address { s: s.to_string(), key })
|
||||
}
|
||||
|
||||
pub fn to_string(&self) -> &str {
|
||||
&self.s
|
||||
}
|
||||
|
||||
pub fn pubkey(&self) -> &[u8; PUBKEY_SIZE] {
|
||||
&self.key
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for Address {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.write_str(&self.s)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn known_addresses() {
|
||||
// From testdata/vectors/tce_vectors.json parties.
|
||||
let cases = [
|
||||
(
|
||||
"8a88e3dd7409f195fd52db2d3cba5d72ca6709bf1d94121bf3748801b40f6f5c",
|
||||
"trust1qz9g3c7awsylr90a2tdj6096t4ev5ecfhuwegysm7d6gsqd5pah4c5jya75",
|
||||
),
|
||||
(
|
||||
"8139770ea87d175f56a35466c34c7ecccb8d8a91b4ee37a25df60f5b8fc9b394",
|
||||
"trust1qzqnjacw4p73wh6k5d2xds6v0mxvhrv2jx6wudazthmq7ku0exeeg4qse9s",
|
||||
),
|
||||
];
|
||||
for (hexkey, addr) in cases {
|
||||
let pk = hex::decode(hexkey).unwrap();
|
||||
let a = Address::from_pubkey(&pk).unwrap();
|
||||
assert_eq!(a.to_string(), addr);
|
||||
let b = Address::parse(addr).unwrap();
|
||||
assert_eq!(b.to_string(), addr);
|
||||
let want: [u8; 32] = pk[..].try_into().unwrap();
|
||||
assert_eq!(b.pubkey(), &want);
|
||||
}
|
||||
}
|
||||
}
|
||||
60
src/autostart.rs
Normal file
60
src/autostart.rs
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
//! Autostart integration (`auto-launch`): register/unregister the app at
|
||||
//! login. Uses the XDG autostart entry on Linux, the run/registry key on
|
||||
//! Windows and the login item on macOS via the underlying crate.
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct Autostart {
|
||||
inner: auto_launch::AutoLaunch,
|
||||
}
|
||||
|
||||
/// Application identity used for both the autostart entry and notifications.
|
||||
pub const APP_NAME: &str = "niko-trust";
|
||||
|
||||
impl Autostart {
|
||||
/// Builds an autostart registration for the currently running executable.
|
||||
///
|
||||
/// `current_exe` is normally `std::env::current_exe()`. A silently-missing
|
||||
/// binary is not fatal: the app can still run, it just won't autostart.
|
||||
pub fn new(current_exe: &Path) -> Result<Autostart, auto_launch::Error> {
|
||||
let path = current_exe
|
||||
.to_str()
|
||||
.ok_or(auto_launch::Error::AppPathNotSpecified)?;
|
||||
let inner = auto_launch::AutoLaunchBuilder::new()
|
||||
.set_app_name(APP_NAME)
|
||||
.set_app_path(path)
|
||||
.build()?;
|
||||
Ok(Autostart { inner })
|
||||
}
|
||||
|
||||
/// Idempotently registers the app to start at login.
|
||||
pub fn enable(&self) -> Result<(), auto_launch::Error> {
|
||||
self.inner.enable()
|
||||
}
|
||||
|
||||
/// Idempotently removes the login entry.
|
||||
pub fn disable(&self) -> Result<(), auto_launch::Error> {
|
||||
self.inner.disable()
|
||||
}
|
||||
|
||||
pub fn is_enabled(&self) -> Result<bool, auto_launch::Error> {
|
||||
self.inner.is_enabled()
|
||||
}
|
||||
}
|
||||
|
||||
/// Writes nothing, never errors; useful when the caller cannot find the
|
||||
/// running binary.
|
||||
pub fn noop() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn app_name_is_stable() {
|
||||
assert_eq!(APP_NAME, "niko-trust");
|
||||
}
|
||||
}
|
||||
83
src/book.rs
Normal file
83
src/book.rs
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
//! Local address book: memorable labels for trust1… addresses.
|
||||
//!
|
||||
//! Entries map an address string to a free-form label ("Niko"); persistence
|
||||
//! lives in the vault file. The display helper renders known addresses as
|
||||
//! `Label (trust1qz8jr…7tx6w)` and unknown ones as just the shortened address.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
pub struct AddressBook {
|
||||
entries: BTreeMap<String, String>,
|
||||
}
|
||||
|
||||
impl AddressBook {
|
||||
pub fn from_entries(entries: BTreeMap<String, String>) -> AddressBook {
|
||||
AddressBook { entries }
|
||||
}
|
||||
|
||||
pub fn entries(&self) -> &BTreeMap<String, String> {
|
||||
&self.entries
|
||||
}
|
||||
|
||||
pub fn label_of(&self, addr: &str) -> Option<&str> {
|
||||
self.entries.get(addr).map(|s| s.as_str())
|
||||
}
|
||||
|
||||
/// Sets (or clears, when `label` is blank) the label for an address.
|
||||
/// Returns true when the map changed.
|
||||
pub fn set_label(&mut self, addr: &str, label: &str) -> bool {
|
||||
let label = label.trim();
|
||||
if label.is_empty() {
|
||||
self.entries.remove(addr).is_some()
|
||||
} else {
|
||||
match self.entries.get(addr) {
|
||||
Some(cur) if cur == label => false,
|
||||
_ => {
|
||||
self.entries.insert(addr.to_string(), label.to_string());
|
||||
true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Compact display form: `Niko (trust1qz8jr…7tx6w)` when known, otherwise
|
||||
/// just the shortened address.
|
||||
pub fn display(&self, addr: &str) -> String {
|
||||
match self.label_of(addr) {
|
||||
Some(l) => format!("{l} ({})", short_addr(addr)),
|
||||
None => short_addr(addr),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Bech32 addresses are long; keep a compact form for the UI.
|
||||
pub fn short_addr(a: &str) -> String {
|
||||
if a.len() <= 20 {
|
||||
a.to_string()
|
||||
} else {
|
||||
format!("{}…{}", &a[..10], &a[a.len() - 6..])
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn display_known_and_unknown() {
|
||||
let mut b = AddressBook::default();
|
||||
let addr = "trust1qz8jrkwdnqscwrvptu69z6ntd2dp092s0fk25ayq94qlzsx649dszg7tx6w";
|
||||
assert_eq!(b.display(addr), short_addr(addr));
|
||||
|
||||
assert!(b.set_label(addr, "Niko"));
|
||||
assert_eq!(b.display(addr), format!("Niko ({})", short_addr(addr)));
|
||||
assert_eq!(b.label_of(addr), Some("Niko"));
|
||||
|
||||
// Blank label clears the entry.
|
||||
b.set_label(addr, " ");
|
||||
assert_eq!(b.display(addr), short_addr(addr));
|
||||
// Clearing a missing entry reports no change.
|
||||
assert!(!b.set_label(addr, ""));
|
||||
}
|
||||
}
|
||||
15
src/crypto.rs
Normal file
15
src/crypto.rs
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
//! Sha-256 helpers: object content addressing.
|
||||
|
||||
use sha2::{Digest, Sha256};
|
||||
|
||||
/// SHA-256 of `data` as a `[u8; 32]`.
|
||||
pub fn sha256(data: &[u8]) -> [u8; 32] {
|
||||
let mut h = Sha256::new();
|
||||
h.update(data);
|
||||
h.finalize().into()
|
||||
}
|
||||
|
||||
/// Lowercase-hex content id of TCE bytes (the protocol object id).
|
||||
pub fn object_id(tce: &[u8]) -> String {
|
||||
hex::encode(sha256(tce))
|
||||
}
|
||||
278
src/inbox.rs
Normal file
278
src/inbox.rs
Normal file
|
|
@ -0,0 +1,278 @@
|
|||
//! Inbox: dedup, history and time validation for incoming approval requests.
|
||||
//!
|
||||
//! The relay returns a request in every poll until it is answered
|
||||
//! (one-response-per-request means an answered request stays listed after the
|
||||
//! response exists). The inbox keeps a seen-set so a request is surfaced only
|
||||
//! once, records the action taken for history, and refuses to act on requests
|
||||
//! whose window has lapsed or that arrived before they were supposed to be
|
||||
//! valid (clock skew, PROTOCOL.md §13.1).
|
||||
|
||||
use std::collections::{HashMap, HashSet};
|
||||
|
||||
use crate::address::Address;
|
||||
use crate::protocol::{Decision, MAX_CLOCK_SKEW};
|
||||
use crate::relay::IncomingRequest;
|
||||
|
||||
/// The decision a request ended with, or none if still pending.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Outcome {
|
||||
Pending,
|
||||
Approved,
|
||||
Denied,
|
||||
/// Left unanswered and its window lapsed.
|
||||
TimedOut,
|
||||
/// Was not answered because it was already stale when seen.
|
||||
Stale,
|
||||
}
|
||||
|
||||
/// One item of history, for display and audit.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct HistoryItem {
|
||||
pub request_id_hex: String,
|
||||
pub sender: Address,
|
||||
pub action: String,
|
||||
pub message: String,
|
||||
pub created_at: u64,
|
||||
pub expires_at: u64,
|
||||
pub outcome: Outcome,
|
||||
}
|
||||
|
||||
/// Is `now` inside the request window with the ±120s skew allowance?
|
||||
/// Mirrors the reference `ValidateCurrent` (PROTOCOL.md §13.1): a request may
|
||||
/// be shown up to SKEW after expiry, and one whose creation is more than SKEW
|
||||
/// in the future is treated as invalid rather than trusted.
|
||||
pub fn in_window(created_at: u64, expires_at: u64, now: u64) -> bool {
|
||||
if created_at > now && created_at - now > MAX_CLOCK_SKEW {
|
||||
return false; // from the future beyond skew
|
||||
}
|
||||
if now > expires_at && now - expires_at > MAX_CLOCK_SKEW {
|
||||
return false; // long expired
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
/// Plain in-memory inbox. Owned by one poll loop / UI controller.
|
||||
#[derive(Debug, Default)]
|
||||
pub struct Inbox {
|
||||
/// request_id bytes already surfaced (hex kept for history lookup).
|
||||
known: HashSet<[u8; 32]>,
|
||||
/// Filled-in history: request_id bytes -> item.
|
||||
history: HashMap<[u8; 32], HistoryItem>,
|
||||
}
|
||||
|
||||
impl Inbox {
|
||||
pub fn new() -> Inbox {
|
||||
Inbox::default()
|
||||
}
|
||||
|
||||
/// Number of requests currently pending (seen, not yet acted on).
|
||||
pub fn pending_count(&self) -> usize {
|
||||
self.history.values().filter(|h| h.outcome == Outcome::Pending).count()
|
||||
}
|
||||
|
||||
/// Consolidated, most-recent-first list for display.
|
||||
pub fn history(&self) -> Vec<&HistoryItem> {
|
||||
let mut v: Vec<&HistoryItem> = self.history.values().collect();
|
||||
v.sort_by_key(|h| std::cmp::Reverse(h.created_at));
|
||||
v
|
||||
}
|
||||
|
||||
/// Whether this request has already been surfaced.
|
||||
pub fn is_known(&self, request_id: &[u8; 32]) -> bool {
|
||||
self.known.contains(request_id)
|
||||
}
|
||||
|
||||
/// Registers the outcome of a surfaced request.
|
||||
pub fn set_outcome(&mut self, request_id: &[u8; 32], outcome: Outcome) {
|
||||
if let Some(item) = self.history.get_mut(request_id) {
|
||||
item.outcome = outcome;
|
||||
}
|
||||
}
|
||||
|
||||
/// Feeds a batch of incoming requests: records all *newly seen* ones into
|
||||
/// history and returns them (caller then reconciles each against the
|
||||
/// relay's responses feed before deciding what to surface).
|
||||
///
|
||||
/// The initial outcome reflects the time window only (Pending / TimedOut /
|
||||
/// Stale); `set_outcome` refines it with the actual decision afterwards.
|
||||
/// Requests outside the window are never actionable regardless of outcome.
|
||||
pub fn observe<'a>(
|
||||
&mut self,
|
||||
now: u64,
|
||||
incoming: &'a [IncomingRequest],
|
||||
) -> Vec<&'a IncomingRequest> {
|
||||
let mut new_ones: Vec<&'a IncomingRequest> = Vec::new();
|
||||
for r in incoming {
|
||||
let id = &r.request_id;
|
||||
if !self.known.insert(*id) {
|
||||
continue; // already surfaced
|
||||
}
|
||||
// Record regardless of validity, so an expired prompt is visible.
|
||||
let outcome = if !in_window(r.request.created_at, r.request.expires_at, now) {
|
||||
if r.request.expires_at != 0 && now > r.request.expires_at {
|
||||
Outcome::TimedOut
|
||||
} else {
|
||||
Outcome::Stale
|
||||
}
|
||||
} else {
|
||||
Outcome::Pending
|
||||
};
|
||||
let mut item = history_item(r);
|
||||
item.outcome = outcome;
|
||||
self.history.insert(*id, item);
|
||||
new_ones.push(r);
|
||||
}
|
||||
new_ones
|
||||
}
|
||||
|
||||
/// Recorded outcome for a request, if it has been seen.
|
||||
pub fn outcome_of(&self, request_id: &[u8; 32]) -> Option<Outcome> {
|
||||
self.history.get(request_id).map(|h| h.outcome)
|
||||
}
|
||||
|
||||
/// Restores persisted history after a restart: items become known (so
|
||||
/// they are never re-surfaced as prompts) and show up in history with
|
||||
/// their saved outcomes. Bad entries are skipped silently.
|
||||
pub fn restore(&mut self, items: Vec<HistoryItem>) {
|
||||
for h in items {
|
||||
let Ok(id_bytes) = hex::decode(&h.request_id_hex) else { continue };
|
||||
let Ok(id) = <[u8; 32]>::try_from(id_bytes) else { continue };
|
||||
self.known.insert(id);
|
||||
self.history.insert(id, h);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn history_item(r: &IncomingRequest) -> HistoryItem {
|
||||
HistoryItem {
|
||||
request_id_hex: hex::encode(r.request_id),
|
||||
sender: r.sender.clone(),
|
||||
action: r.request.action.clone(),
|
||||
message: r.request.message.clone(),
|
||||
created_at: r.request.created_at,
|
||||
expires_at: r.request.expires_at,
|
||||
outcome: Outcome::Pending,
|
||||
}
|
||||
}
|
||||
|
||||
/// Conviction: allow or deny, carried on the action taken for a request.
|
||||
impl From<Decision> for Outcome {
|
||||
fn from(d: Decision) -> Self {
|
||||
match d {
|
||||
Decision::Allow => Outcome::Approved,
|
||||
Decision::Deny => Outcome::Denied,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Human-readable reason shown next to history entries.
|
||||
pub fn outcome_label(o: Outcome) -> &'static str {
|
||||
match o {
|
||||
Outcome::Pending => "pending",
|
||||
Outcome::Approved => "approved",
|
||||
Outcome::Denied => "denied",
|
||||
Outcome::TimedOut => "expired",
|
||||
Outcome::Stale => "stale",
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::protocol::ApprovalRequest;
|
||||
use crate::signer::Signer;
|
||||
use crate::tce::NONCE_SIZE;
|
||||
|
||||
fn incoming(me: &Signer, sender: &Signer, action: &str) -> IncomingRequest {
|
||||
let request = ApprovalRequest {
|
||||
sender: sender.pubkey(),
|
||||
recipient: me.pubkey(),
|
||||
action: action.to_string(),
|
||||
payload: Default::default(),
|
||||
message: "m".to_string(),
|
||||
created_at: 1_700_000_000,
|
||||
expires_at: 1_700_000_030,
|
||||
nonce: [0u8; NONCE_SIZE],
|
||||
};
|
||||
IncomingRequest {
|
||||
request,
|
||||
tce: vec![],
|
||||
request_id: [0x11; 32],
|
||||
sender: Address::from_pubkey(&sender.pubkey()).unwrap(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dedup_surfaces_once() {
|
||||
let me = Signer::generate().unwrap();
|
||||
let sender = Signer::generate().unwrap();
|
||||
let mut boxed = Inbox::new();
|
||||
let r = incoming(&me, &sender, "login");
|
||||
|
||||
let first = [r.clone()];
|
||||
let one = boxed.observe(1_700_000_020, &first);
|
||||
assert_eq!(one.len(), 1);
|
||||
|
||||
// Same request again -> nothing new.
|
||||
let again = [r.clone()];
|
||||
let two = boxed.observe(1_700_000_025, &again);
|
||||
assert!(two.is_empty());
|
||||
assert_eq!(boxed.pending_count(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn window_and_outcomes() {
|
||||
let me = Signer::generate().unwrap();
|
||||
let sender = Signer::generate().unwrap();
|
||||
let mut boxed = Inbox::new();
|
||||
let r = incoming(&me, &sender, "x");
|
||||
|
||||
// Inside window -> pending & fresh.
|
||||
let inside_batch = [r.clone()];
|
||||
let inside = boxed.observe(1_700_000_010, &inside_batch);
|
||||
assert_eq!(inside.len(), 1);
|
||||
|
||||
// Long after expiry (beyond the skew allowance) -> TimedOut, recorded
|
||||
// but never actionable. expires=1_700_000_030, +120s skew => valid
|
||||
// until 1_700_000_150; 200 is past that.
|
||||
let stale_id = [0x22; 32];
|
||||
let mut stale = r.clone();
|
||||
stale.request_id = stale_id;
|
||||
let stale_batch = [stale];
|
||||
let out = boxed.observe(1_700_000_200, &stale_batch);
|
||||
assert_eq!(out.len(), 1, "expired request is recorded as new");
|
||||
assert_eq!(
|
||||
boxed.outcome_of(&stale_id),
|
||||
Some(Outcome::TimedOut),
|
||||
"expired request must not be pending"
|
||||
);
|
||||
|
||||
// Long in the future -> not valid yet (Stale), not actionable.
|
||||
let f = [0x33; 32];
|
||||
let mut future = r.clone();
|
||||
future.request_id = f;
|
||||
future.request.created_at = 1_800_000_000;
|
||||
future.request.expires_at = 1_800_000_030;
|
||||
let future_batch = [future];
|
||||
let out = boxed.observe(1_700_000_000, &future_batch);
|
||||
assert_eq!(out.len(), 1);
|
||||
assert_eq!(boxed.outcome_of(&f), Some(Outcome::Stale));
|
||||
}
|
||||
|
||||
// The clippy placeholder above is removed; this test documents outcome
|
||||
// transitions via set_outcome.
|
||||
#[test]
|
||||
fn outcome_changes() {
|
||||
let me = Signer::generate().unwrap();
|
||||
let sender = Signer::generate().unwrap();
|
||||
let mut boxed = Inbox::new();
|
||||
let r = incoming(&me, &sender, "login");
|
||||
let b = [r.clone()];
|
||||
boxed.observe(1_700_000_020, &b);
|
||||
let id = r.request_id;
|
||||
boxed.set_outcome(&id, Outcome::from(Decision::Allow));
|
||||
let h = boxed.history();
|
||||
assert_eq!(h[0].outcome, Outcome::Approved);
|
||||
assert_eq!(outcome_label(h[0].outcome), "approved");
|
||||
}
|
||||
}
|
||||
241
src/keyring.rs
Normal file
241
src/keyring.rs
Normal file
|
|
@ -0,0 +1,241 @@
|
|||
//! Keyring: encrypts the 32-byte identity seed at rest with a passphrase.
|
||||
//!
|
||||
//! KDF = Argon2id (default params, 19 MiB); AEAD = XChaCha20-Poly1305 with a
|
||||
//! 24-byte random nonce; salt 16 bytes. The identity file is written with
|
||||
//! 0o600 permissions mirroring the seed's sensitivity. A wrong passphrase
|
||||
//! fails the AEAD tag check, so no separate password verifier is needed.
|
||||
|
||||
use std::fs::OpenOptions;
|
||||
use std::io::Write;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use aead::Payload;
|
||||
use aead::{Aead, KeyInit};
|
||||
use chacha20poly1305::{XChaCha20Poly1305, XNonce};
|
||||
use zeroize::Zeroizing;
|
||||
|
||||
use crate::signer::Signer;
|
||||
|
||||
const FILE_VERSION: u8 = 1;
|
||||
const KDF_M: u32 = 19 * 1024; // 19 MiB (matches Argon2 default)
|
||||
const KDF_T: u32 = 2;
|
||||
const KDF_P: u32 = 1;
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum KeyringError {
|
||||
Io(std::io::Error),
|
||||
Json(serde_json::Error),
|
||||
/// Missing identity file or wrong passphrase.
|
||||
Decrypt(String),
|
||||
/// Directory creation failed.
|
||||
Setup(String),
|
||||
}
|
||||
|
||||
impl std::fmt::Display for KeyringError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
KeyringError::Io(e) => write!(f, "io: {e}"),
|
||||
KeyringError::Json(e) => write!(f, "json: {e}"),
|
||||
KeyringError::Decrypt(m) => write!(f, "{m}"),
|
||||
KeyringError::Setup(m) => write!(f, "{m}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for KeyringError {}
|
||||
|
||||
fn open_private(path: &Path) -> Result<std::fs::File, std::io::Error> {
|
||||
let f = OpenOptions::new().create(true).write(true).truncate(true).open(path)?;
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
f.set_permissions(std::fs::Permissions::from_mode(0o600))?;
|
||||
}
|
||||
Ok(f)
|
||||
}
|
||||
|
||||
#[derive(serde::Serialize, serde::Deserialize)]
|
||||
struct FileFormat {
|
||||
v: u8,
|
||||
kdf: String,
|
||||
m: u32,
|
||||
t: u32,
|
||||
p: u32,
|
||||
salt: String, // base64 16
|
||||
nonce: String, // base64 24
|
||||
cipher: String, // base64 ciphertext
|
||||
}
|
||||
|
||||
fn derive_key(password: &str, salt: &[u8]) -> Result<Zeroizing<[u8; 32]>, KeyringError> {
|
||||
use argon2::{Algorithm, Argon2, Params, Version};
|
||||
let params = Params::new(KDF_M, KDF_T, KDF_P, Some(32))
|
||||
.map_err(|e| KeyringError::Setup(format!("argon2 params: {e}")))?;
|
||||
let argon = Argon2::new(Algorithm::Argon2id, Version::V0x13, params);
|
||||
let mut key = Zeroizing::new([0u8; 32]);
|
||||
argon
|
||||
.hash_password_into(password.as_bytes(), salt, key.as_mut())
|
||||
.map_err(|e| KeyringError::Setup(format!("argon2: {e}")))?;
|
||||
Ok(key)
|
||||
}
|
||||
|
||||
/// Loads or creates the identity store in the app config directory.
|
||||
pub struct Keyring {
|
||||
dir: PathBuf,
|
||||
}
|
||||
|
||||
impl Keyring {
|
||||
pub fn new(dir: PathBuf) -> Result<Keyring, KeyringError> {
|
||||
std::fs::create_dir_all(&dir)
|
||||
.map_err(|e| KeyringError::Setup(format!("mkdir {}: {e}", dir.display())))?;
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let _ = std::fs::set_permissions(&dir, std::fs::Permissions::from_mode(0o700));
|
||||
}
|
||||
Ok(Keyring { dir })
|
||||
}
|
||||
|
||||
pub fn identity_file(&self) -> PathBuf {
|
||||
self.dir.join("identity.json")
|
||||
}
|
||||
|
||||
pub fn has_identity(&self) -> bool {
|
||||
self.identity_file().exists()
|
||||
}
|
||||
|
||||
/// Encrypts a freshly generated seed under `password` and writes it out.
|
||||
pub fn save_new_key(&self, password: &str, seed: &[u8; 32]) -> Result<(), KeyringError> {
|
||||
let json = serde_json::to_vec_pretty(&encrypt_seed(password, seed)?)
|
||||
.map_err(KeyringError::Json)?;
|
||||
let mut f = open_private(&self.identity_file()).map_err(KeyringError::Io)?;
|
||||
f.write_all(&json).map_err(KeyringError::Io)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Decrypts the stored seed. Wrong passphrase => Err(Decrypt).
|
||||
pub fn load_key(&self, password: &str) -> Result<[u8; 32], KeyringError> {
|
||||
let raw = std::fs::read_to_string(self.identity_file())
|
||||
.map_err(|_| KeyringError::Decrypt("identity file missing".into()))?;
|
||||
let blob: serde_json::Value = serde_json::from_str(&raw)
|
||||
.map_err(|e| KeyringError::Decrypt(format!("bad file: {e}")))?;
|
||||
decrypt_seed(password, &blob)
|
||||
}
|
||||
|
||||
/// Loads the signer, if present and the passphrase is correct.
|
||||
pub fn load_signer(&self, password: &str) -> Result<Signer, KeyringError> {
|
||||
let seed = self.load_key(password)?;
|
||||
Signer::from_seed(seed).map_err(|e| KeyringError::Decrypt(format!("bad seed: {e}")))
|
||||
}
|
||||
}
|
||||
|
||||
/// Encrypts a seed into an identity JSON value (the vault stores exactly this).
|
||||
pub fn encrypt_seed(password: &str, seed: &[u8; 32]) -> Result<serde_json::Value, KeyringError> {
|
||||
let mut salt = [0u8; 16];
|
||||
getrandom::fill(&mut salt).map_err(|e| KeyringError::Setup(format!("rng: {e}")))?;
|
||||
let key = derive_key(password, &salt)?;
|
||||
|
||||
let cipher = XChaCha20Poly1305::new_from_slice(key.as_ref())
|
||||
.expect("32-byte key from argon2id");
|
||||
let mut nonce_bytes = [0u8; 24];
|
||||
getrandom::fill(&mut nonce_bytes).map_err(|e| KeyringError::Setup(format!("rng: {e}")))?;
|
||||
let nonce = XNonce::from(nonce_bytes);
|
||||
let payload = Payload { msg: seed, aad: b"niko-trust/identity/v1" };
|
||||
let ciphertext = cipher
|
||||
.encrypt(&nonce, payload)
|
||||
.map_err(|_| KeyringError::Setup("encrypt failed".into()))?;
|
||||
|
||||
let fmt = FileFormat {
|
||||
v: FILE_VERSION,
|
||||
kdf: "argon2id".into(),
|
||||
m: KDF_M,
|
||||
t: KDF_T,
|
||||
p: KDF_P,
|
||||
salt: base64::Engine::encode(&base64::engine::general_purpose::STANDARD, salt),
|
||||
nonce: base64::Engine::encode(&base64::engine::general_purpose::STANDARD, nonce_bytes),
|
||||
cipher: base64::Engine::encode(&base64::engine::general_purpose::STANDARD, ciphertext),
|
||||
};
|
||||
serde_json::to_value(&fmt).map_err(KeyringError::Json)
|
||||
}
|
||||
|
||||
/// Decrypts a seed from an identity JSON value (vault-stored). A wrong
|
||||
/// passphrase fails the AEAD tag check => Err(Decrypt).
|
||||
pub fn decrypt_seed(
|
||||
password: &str,
|
||||
blob: &serde_json::Value,
|
||||
) -> Result<[u8; 32], KeyringError> {
|
||||
let fmt: FileFormat =
|
||||
serde_json::from_value(blob.clone()).map_err(|e| KeyringError::Decrypt(format!("bad blob: {e}")))?;
|
||||
if fmt.v != FILE_VERSION || fmt.kdf != "argon2id" {
|
||||
return Err(KeyringError::Decrypt("unsupported key file".into()));
|
||||
}
|
||||
let salt = base64::Engine::decode(&base64::engine::general_purpose::STANDARD, &fmt.salt)
|
||||
.map_err(|_| KeyringError::Decrypt("bad salt".into()))?;
|
||||
let nonce_b = base64::Engine::decode(&base64::engine::general_purpose::STANDARD, &fmt.nonce)
|
||||
.map_err(|_| KeyringError::Decrypt("bad nonce".into()))?;
|
||||
let ct = base64::Engine::decode(&base64::engine::general_purpose::STANDARD, &fmt.cipher)
|
||||
.map_err(|_| KeyringError::Decrypt("bad cipher".into()))?;
|
||||
|
||||
// Use the params stored in the file.
|
||||
let key = derive_key_with_params(password, &salt, fmt.m, fmt.t, fmt.p)?;
|
||||
|
||||
let cipher = XChaCha20Poly1305::new_from_slice(key.as_ref()).expect("32-byte key from argon2id");
|
||||
let nonce_bytes: [u8; 24] = nonce_b
|
||||
.try_into()
|
||||
.map_err(|_| KeyringError::Decrypt("bad nonce length".into()))?;
|
||||
let nonce = XNonce::from(nonce_bytes);
|
||||
let payload = Payload { msg: ct.as_ref(), aad: b"niko-trust/identity/v1" };
|
||||
let plain = cipher.decrypt(&nonce, payload).map_err(|_| {
|
||||
KeyringError::Decrypt("неверный пароль или повреждённый файл ключа".into())
|
||||
})?;
|
||||
let seed: [u8; 32] = plain.try_into().map_err(|_| KeyringError::Decrypt("bad seed length".into()))?;
|
||||
Ok(seed)
|
||||
}
|
||||
|
||||
fn derive_key_with_params(
|
||||
password: &str,
|
||||
salt: &[u8],
|
||||
m: u32,
|
||||
t: u32,
|
||||
p: u32,
|
||||
) -> Result<Zeroizing<[u8; 32]>, KeyringError> {
|
||||
use argon2::{Algorithm, Argon2, Params, Version};
|
||||
let params =
|
||||
Params::new(m, t, p, Some(32)).map_err(|e| KeyringError::Setup(format!("argon2: {e}")))?;
|
||||
let argon = Argon2::new(Algorithm::Argon2id, Version::V0x13, params);
|
||||
let mut key = Zeroizing::new([0u8; 32]);
|
||||
argon
|
||||
.hash_password_into(password.as_bytes(), salt, key.as_mut())
|
||||
.map_err(|e| KeyringError::Setup(format!("argon2: {e}")))?;
|
||||
Ok(key)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn roundtrip_and_wrong_password() {
|
||||
let dir = std::env::temp_dir().join(format!("nktrst-{}", std::process::id()));
|
||||
let kr = Keyring::new(dir.clone()).unwrap();
|
||||
assert!(!kr.has_identity());
|
||||
let seed = [0xabu8; 32];
|
||||
kr.save_new_key("correct horse", &seed).unwrap();
|
||||
assert!(kr.has_identity());
|
||||
assert_eq!(kr.load_key("correct horse").unwrap(), seed);
|
||||
assert!(kr.load_key("wrong pass").is_err());
|
||||
// clean up
|
||||
let _ = std::fs::remove_file(kr.identity_file());
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn kdf_params_are_always_writeable() {
|
||||
let dir = std::env::temp_dir().join(format!("nktrst2-{}", std::process::id()));
|
||||
let kr = Keyring::new(dir.clone()).unwrap();
|
||||
let seed = [7u8; 32];
|
||||
kr.save_new_key("x", &seed).unwrap();
|
||||
assert_eq!(kr.load_key("x").unwrap(), seed);
|
||||
let _ = std::fs::remove_file(kr.identity_file());
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
}
|
||||
17
src/lib.rs
Normal file
17
src/lib.rs
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
//! Niko Trust GUI — core library.
|
||||
//!
|
||||
//! Byte-exact TCE protocol client for the niko_trust relay. The GUI (main.rs,
|
||||
//! feature "gui") builds on these modules.
|
||||
|
||||
pub mod address;
|
||||
pub mod autostart;
|
||||
pub mod book;
|
||||
pub mod crypto;
|
||||
pub mod inbox;
|
||||
pub mod keyring;
|
||||
pub mod notify;
|
||||
pub mod protocol;
|
||||
pub mod relay;
|
||||
pub mod signer;
|
||||
pub mod tce;
|
||||
pub mod vault;
|
||||
1741
src/main.rs
Normal file
1741
src/main.rs
Normal file
File diff suppressed because it is too large
Load diff
119
src/notify.rs
Normal file
119
src/notify.rs
Normal file
|
|
@ -0,0 +1,119 @@
|
|||
//! Notifications: show an approval request with Approve / Deny buttons.
|
||||
//!
|
||||
//! The notification is fire-and-forget from the UI's point of view (it runs on
|
||||
//! its own thread); the user's decision arrives on a channel. If the backend
|
||||
//! cannot show interactive notifications (or the user dismisses), the caller
|
||||
//! falls back to an in-app prompt.
|
||||
|
||||
use std::sync::mpsc;
|
||||
use std::time::Duration;
|
||||
|
||||
use crate::protocol::Decision;
|
||||
|
||||
/// A native notification with exactly two actions.
|
||||
pub struct ApprovalNotification {
|
||||
/// Sender address, shown as the source line.
|
||||
pub sender: String,
|
||||
/// Request action (opaque).
|
||||
pub action: String,
|
||||
/// Human-readable message.
|
||||
pub message: String,
|
||||
}
|
||||
|
||||
/// Outcome of a user interacting with a notification.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum PromptOutcome {
|
||||
/// The user hit Approve.
|
||||
Approved,
|
||||
/// The user hit Deny.
|
||||
Denied,
|
||||
/// The notification was dismissed or the backend has no interactivity.
|
||||
Ignored,
|
||||
}
|
||||
|
||||
/// Result of showing one notification.
|
||||
pub struct PromptHandle {
|
||||
rx: mpsc::Receiver<PromptOutcome>,
|
||||
}
|
||||
|
||||
impl PromptHandle {
|
||||
/// Blocks until the user answers. Returns immediately if the backend
|
||||
/// reported no interactivity (the handle then yields `Ignored`).
|
||||
pub fn wait(self) -> PromptOutcome {
|
||||
self.rx.recv().unwrap_or(PromptOutcome::Ignored)
|
||||
}
|
||||
|
||||
/// Waits up to `timeout` for an answer; `Ignored` if the user is slow.
|
||||
pub fn wait_timeout(self, timeout: Duration) -> PromptOutcome {
|
||||
self.rx
|
||||
.recv_timeout(timeout)
|
||||
.unwrap_or(PromptOutcome::Ignored)
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether this platform can raise interactive notifications.
|
||||
#[cfg(target_os = "linux")]
|
||||
pub fn interactive_supported() -> bool {
|
||||
notify_rust::dbus_stack().is_some()
|
||||
}
|
||||
|
||||
/// Windows/toast support is not wired yet; the in-app prompt covers it.
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
pub fn interactive_supported() -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
/// Shows the notification and returns a handle to its outcome.
|
||||
///
|
||||
/// The "not interactive" case still shows a plain, non-interactive toast so
|
||||
/// the user at least sees the request, and returns a handle that yields
|
||||
/// `Ignored` immediately.
|
||||
#[cfg(target_os = "linux")]
|
||||
pub fn show(n: ApprovalNotification) -> PromptHandle {
|
||||
let (tx, rx) = mpsc::channel();
|
||||
let mut notif = notify_rust::Notification::new();
|
||||
notif.summary(&format!("Approval requested: {}", n.action));
|
||||
notif.body(&format!("{}\nfrom: {}", n.message, n.sender));
|
||||
notif.appname("niko-trust");
|
||||
notif.action("approve", "Approve");
|
||||
notif.action("deny", "Deny");
|
||||
|
||||
let handle = notif.show();
|
||||
match handle {
|
||||
Ok(h) => {
|
||||
std::thread::spawn(move || {
|
||||
h.wait_for_action(|a| {
|
||||
let _ = tx.send(match a {
|
||||
"approve" => PromptOutcome::Approved,
|
||||
"deny" => PromptOutcome::Denied,
|
||||
_ => PromptOutcome::Ignored,
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
Err(_) => {
|
||||
// Backend down / non-interactive: what we showed (if anything)
|
||||
// cannot answer. Report Ignored so the UI shows its own dialog.
|
||||
let _ = tx.send(PromptOutcome::Ignored);
|
||||
}
|
||||
}
|
||||
PromptHandle { rx }
|
||||
}
|
||||
|
||||
/// Placeholder backend: no native toast outside Linux yet, so report `Ignored`
|
||||
/// right away — the GUI falls back to its in-app prompt card.
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
pub fn show(_n: ApprovalNotification) -> PromptHandle {
|
||||
let (tx, rx) = mpsc::channel();
|
||||
let _ = tx.send(PromptOutcome::Ignored);
|
||||
PromptHandle { rx }
|
||||
}
|
||||
|
||||
/// Maps a prompt outcome to a protocol decision, if the user chose one.
|
||||
pub fn to_decision(o: PromptOutcome) -> Option<Decision> {
|
||||
match o {
|
||||
PromptOutcome::Approved => Some(Decision::Allow),
|
||||
PromptOutcome::Denied => Some(Decision::Deny),
|
||||
PromptOutcome::Ignored => None,
|
||||
}
|
||||
}
|
||||
402
src/protocol.rs
Normal file
402
src/protocol.rs
Normal file
|
|
@ -0,0 +1,402 @@
|
|||
//! Protocol objects: field order and rules for the six signed object types.
|
||||
//! Field order here is exactly the order in PROTOCOL.md section 8 and matches
|
||||
//! the Go reference (internal/protocol).
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use crate::address::{validate_pubkey, AddressError};
|
||||
use crate::crypto::sha256;
|
||||
use crate::tce::decode::Decoder;
|
||||
use crate::tce::encode::Encoder;
|
||||
use crate::tce::value::Value;
|
||||
use crate::tce::TceError;
|
||||
|
||||
pub const MAX_CLOCK_SKEW: u64 = 120;
|
||||
|
||||
pub use crate::tce::{
|
||||
MAX_ACTION_LEN, MAX_ALIAS_LEN, MAX_AUDIENCE_LEN, MAX_AUTH_TCE, MAX_CLAIM_TCE, MAX_IDENTITY_TCE,
|
||||
MAX_MESSAGE_LEN, MAX_REASON_LEN, MAX_REQUEST_TCE, MAX_RESPONSE_TCE, MAX_REVOC_TCE,
|
||||
MAX_SCOPE_LEN,
|
||||
};
|
||||
|
||||
fn validate_identity(pubkey: &[u8; 32]) -> Result<(), AddressError> {
|
||||
validate_pubkey(pubkey)
|
||||
}
|
||||
|
||||
fn check_end(d: &Decoder) -> Result<(), TceError> {
|
||||
d.end()
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// IdentityRegistration (0x01)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[derive(Debug, Clone, Default, PartialEq)]
|
||||
pub struct Identity {
|
||||
pub pubkey: [u8; crate::tce::PUBKEY_SIZE],
|
||||
pub alias: String,
|
||||
pub created_at: u64,
|
||||
}
|
||||
|
||||
pub fn encode_identity(o: &Identity) -> Result<Vec<u8>, TceError> {
|
||||
validate_identity(&o.pubkey).map_err(|_| TceError::Protocol("identity: invalid key".into()))?;
|
||||
let mut e = Encoder::new();
|
||||
e.header(crate::tce::TAG_IDENTITY);
|
||||
e.identity(&o.pubkey);
|
||||
e.string(&o.alias, MAX_ALIAS_LEN);
|
||||
e.timestamp(o.created_at, false);
|
||||
e.check_size(MAX_IDENTITY_TCE);
|
||||
e.bytes()
|
||||
}
|
||||
|
||||
pub fn decode_identity(b: &[u8]) -> Result<Identity, TceError> {
|
||||
if b.len() > MAX_IDENTITY_TCE {
|
||||
return Err(TceError::ObjectTooLarge);
|
||||
}
|
||||
let mut d = Decoder::new(b);
|
||||
let tag = d.header()?;
|
||||
if tag != crate::tce::TAG_IDENTITY {
|
||||
return Err(TceError::WrongObject);
|
||||
}
|
||||
let pk = d.identity()?;
|
||||
validate_identity(&pk).map_err(|_| TceError::Protocol("identity: invalid key".into()))?;
|
||||
let alias = d.string(MAX_ALIAS_LEN)?;
|
||||
let created_at = d.timestamp(false)?;
|
||||
check_end(&d)?;
|
||||
Ok(Identity { pubkey: pk, alias, created_at })
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Claim (0x02)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[derive(Debug, Clone, Default, PartialEq)]
|
||||
pub struct Claim {
|
||||
pub issuer: [u8; crate::tce::PUBKEY_SIZE],
|
||||
pub subject: [u8; crate::tce::PUBKEY_SIZE],
|
||||
pub claims: BTreeMap<String, Value>,
|
||||
pub created_at: u64,
|
||||
pub expires_at: u64,
|
||||
pub serial: u64,
|
||||
pub nonce: [u8; crate::tce::NONCE_SIZE],
|
||||
}
|
||||
|
||||
pub fn encode_claim(o: &Claim) -> Result<Vec<u8>, TceError> {
|
||||
validate_identity(&o.issuer).map_err(|_| TceError::Protocol("claim: issuer".into()))?;
|
||||
validate_identity(&o.subject).map_err(|_| TceError::Protocol("claim: subject".into()))?;
|
||||
if o.nonce.len() != crate::tce::NONCE_SIZE {
|
||||
return Err(TceError::FieldSize);
|
||||
}
|
||||
if o.expires_at != 0 && o.expires_at <= o.created_at {
|
||||
return Err(TceError::Expiry);
|
||||
}
|
||||
let mut e = Encoder::new();
|
||||
e.header(crate::tce::TAG_CLAIM);
|
||||
e.identity(&o.issuer);
|
||||
e.identity(&o.subject);
|
||||
e.map(&o.claims, 1);
|
||||
e.timestamp(o.created_at, false);
|
||||
e.timestamp(o.expires_at, true);
|
||||
e.uvarint(o.serial);
|
||||
e.fixed_bytes(&o.nonce, crate::tce::NONCE_SIZE);
|
||||
e.check_size(MAX_CLAIM_TCE);
|
||||
e.bytes()
|
||||
}
|
||||
|
||||
pub fn decode_claim(b: &[u8]) -> Result<Claim, TceError> {
|
||||
if b.len() > MAX_CLAIM_TCE {
|
||||
return Err(TceError::ObjectTooLarge);
|
||||
}
|
||||
let mut d = Decoder::new(b);
|
||||
let tag = d.header()?;
|
||||
if tag != crate::tce::TAG_CLAIM {
|
||||
return Err(TceError::WrongObject);
|
||||
}
|
||||
let issuer = d.identity()?;
|
||||
validate_identity(&issuer).map_err(|_| TceError::Protocol("claim: issuer".into()))?;
|
||||
let subject = d.identity()?;
|
||||
validate_identity(&subject).map_err(|_| TceError::Protocol("claim: subject".into()))?;
|
||||
let claims = d.map(1)?;
|
||||
let created_at = d.timestamp(false)?;
|
||||
let expires_at = d.timestamp(true)?;
|
||||
if expires_at != 0 && expires_at <= created_at {
|
||||
return Err(TceError::Expiry);
|
||||
}
|
||||
let serial = d.uvarint()?;
|
||||
let nonce_b = d.fixed_bytes(crate::tce::NONCE_SIZE)?;
|
||||
check_end(&d)?;
|
||||
let mut nonce = [0u8; crate::tce::NONCE_SIZE];
|
||||
nonce.copy_from_slice(nonce_b);
|
||||
Ok(Claim { issuer, subject, claims, created_at, expires_at, serial, nonce })
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Revocation (0x03)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[derive(Debug, Clone, Default, PartialEq)]
|
||||
pub struct Revocation {
|
||||
pub issuer: [u8; crate::tce::PUBKEY_SIZE],
|
||||
pub claim_id: [u8; crate::tce::HASH_SIZE],
|
||||
pub reason: String,
|
||||
pub created_at: u64,
|
||||
pub nonce: [u8; crate::tce::NONCE_SIZE],
|
||||
}
|
||||
|
||||
pub fn encode_revocation(o: &Revocation) -> Result<Vec<u8>, TceError> {
|
||||
validate_identity(&o.issuer).map_err(|_| TceError::Protocol("revocation: issuer".into()))?;
|
||||
if o.nonce.len() != crate::tce::NONCE_SIZE {
|
||||
return Err(TceError::FieldSize);
|
||||
}
|
||||
let mut e = Encoder::new();
|
||||
e.header(crate::tce::TAG_REVOCATION);
|
||||
e.identity(&o.issuer);
|
||||
e.fixed_bytes(&o.claim_id, crate::tce::HASH_SIZE);
|
||||
e.string(&o.reason, MAX_REASON_LEN);
|
||||
e.timestamp(o.created_at, false);
|
||||
e.fixed_bytes(&o.nonce, crate::tce::NONCE_SIZE);
|
||||
e.check_size(MAX_REVOC_TCE);
|
||||
e.bytes()
|
||||
}
|
||||
|
||||
pub fn decode_revocation(b: &[u8]) -> Result<Revocation, TceError> {
|
||||
if b.len() > MAX_REVOC_TCE {
|
||||
return Err(TceError::ObjectTooLarge);
|
||||
}
|
||||
let mut d = Decoder::new(b);
|
||||
let tag = d.header()?;
|
||||
if tag != crate::tce::TAG_REVOCATION {
|
||||
return Err(TceError::WrongObject);
|
||||
}
|
||||
let issuer = d.identity()?;
|
||||
validate_identity(&issuer).map_err(|_| TceError::Protocol("revocation: issuer".into()))?;
|
||||
let claim_b = d.fixed_bytes(crate::tce::HASH_SIZE)?;
|
||||
let mut claim_id = [0u8; crate::tce::HASH_SIZE];
|
||||
claim_id.copy_from_slice(claim_b);
|
||||
let reason = d.string(MAX_REASON_LEN)?;
|
||||
let created_at = d.timestamp(false)?;
|
||||
let nonce_b = d.fixed_bytes(crate::tce::NONCE_SIZE)?;
|
||||
check_end(&d)?;
|
||||
let mut nonce = [0u8; crate::tce::NONCE_SIZE];
|
||||
nonce.copy_from_slice(nonce_b);
|
||||
Ok(Revocation { issuer, claim_id, reason, created_at, nonce })
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// ApprovalRequest (0x04)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[derive(Debug, Clone, Default, PartialEq)]
|
||||
pub struct ApprovalRequest {
|
||||
pub sender: [u8; crate::tce::PUBKEY_SIZE],
|
||||
pub recipient: [u8; crate::tce::PUBKEY_SIZE],
|
||||
pub action: String,
|
||||
pub payload: BTreeMap<String, Value>,
|
||||
pub message: String,
|
||||
pub created_at: u64,
|
||||
pub expires_at: u64,
|
||||
pub nonce: [u8; crate::tce::NONCE_SIZE],
|
||||
}
|
||||
|
||||
impl ApprovalRequest {
|
||||
/// Lowercase-hex request id over the exact request bytes.
|
||||
pub fn request_id(&self, tce: &[u8]) -> String {
|
||||
hex::encode(sha256(tce))
|
||||
}
|
||||
}
|
||||
|
||||
pub fn encode_approval_request(o: &ApprovalRequest) -> Result<Vec<u8>, TceError> {
|
||||
validate_identity(&o.sender).map_err(|_| TceError::Protocol("request: sender".into()))?;
|
||||
validate_identity(&o.recipient).map_err(|_| TceError::Protocol("request: recipient".into()))?;
|
||||
if o.nonce.len() != crate::tce::NONCE_SIZE {
|
||||
return Err(TceError::FieldSize);
|
||||
}
|
||||
if o.expires_at <= o.created_at {
|
||||
return Err(TceError::Expiry);
|
||||
}
|
||||
if o.expires_at - o.created_at > crate::tce::MAX_APPROVAL_LIFETIME {
|
||||
return Err(TceError::Lifetime);
|
||||
}
|
||||
let mut e = Encoder::new();
|
||||
e.header(crate::tce::TAG_APPROVAL_REQUEST);
|
||||
e.identity(&o.sender);
|
||||
e.identity(&o.recipient);
|
||||
e.string(&o.action, MAX_ACTION_LEN);
|
||||
e.map(&o.payload, 0);
|
||||
e.string(&o.message, MAX_MESSAGE_LEN);
|
||||
e.timestamp(o.created_at, false);
|
||||
e.timestamp(o.expires_at, false);
|
||||
e.fixed_bytes(&o.nonce, crate::tce::NONCE_SIZE);
|
||||
e.check_size(MAX_REQUEST_TCE);
|
||||
e.bytes()
|
||||
}
|
||||
|
||||
pub fn decode_approval_request(b: &[u8]) -> Result<ApprovalRequest, TceError> {
|
||||
if b.len() > MAX_REQUEST_TCE {
|
||||
return Err(TceError::ObjectTooLarge);
|
||||
}
|
||||
let mut d = Decoder::new(b);
|
||||
let tag = d.header()?;
|
||||
if tag != crate::tce::TAG_APPROVAL_REQUEST {
|
||||
return Err(TceError::WrongObject);
|
||||
}
|
||||
let sender = d.identity()?;
|
||||
validate_identity(&sender).map_err(|_| TceError::Protocol("request: sender".into()))?;
|
||||
let recipient = d.identity()?;
|
||||
validate_identity(&recipient).map_err(|_| TceError::Protocol("request: recipient".into()))?;
|
||||
let action = d.string(MAX_ACTION_LEN)?;
|
||||
let payload = d.map(0)?;
|
||||
let message = d.string(MAX_MESSAGE_LEN)?;
|
||||
let created_at = d.timestamp(false)?;
|
||||
let expires_at = d.timestamp(false)?;
|
||||
if expires_at <= created_at {
|
||||
return Err(TceError::Expiry);
|
||||
}
|
||||
if expires_at - created_at > crate::tce::MAX_APPROVAL_LIFETIME {
|
||||
return Err(TceError::Lifetime);
|
||||
}
|
||||
let nonce_b = d.fixed_bytes(crate::tce::NONCE_SIZE)?;
|
||||
check_end(&d)?;
|
||||
let mut nonce = [0u8; crate::tce::NONCE_SIZE];
|
||||
nonce.copy_from_slice(nonce_b);
|
||||
Ok(ApprovalRequest { sender, recipient, action, payload, message, created_at, expires_at, nonce })
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// ApprovalResponse (0x05)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||
pub enum Decision {
|
||||
#[default]
|
||||
Deny,
|
||||
Allow,
|
||||
}
|
||||
|
||||
impl Decision {
|
||||
pub fn from_u64(v: u64) -> Option<Decision> {
|
||||
match v {
|
||||
crate::tce::DECISION_DENY => Some(Decision::Deny),
|
||||
crate::tce::DECISION_ALLOW => Some(Decision::Allow),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
pub fn as_u64(&self) -> u64 {
|
||||
match self {
|
||||
Decision::Deny => crate::tce::DECISION_DENY,
|
||||
Decision::Allow => crate::tce::DECISION_ALLOW,
|
||||
}
|
||||
}
|
||||
pub fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
Decision::Allow => "allow",
|
||||
Decision::Deny => "deny",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, PartialEq)]
|
||||
pub struct ApprovalResponse {
|
||||
pub request_hash: [u8; crate::tce::HASH_SIZE],
|
||||
pub responder: [u8; crate::tce::PUBKEY_SIZE],
|
||||
pub decision: Decision,
|
||||
pub created_at: u64,
|
||||
pub nonce: [u8; crate::tce::NONCE_SIZE],
|
||||
}
|
||||
|
||||
pub fn encode_approval_response(o: &ApprovalResponse) -> Result<Vec<u8>, TceError> {
|
||||
if !matches!(o.decision, Decision::Deny | Decision::Allow) {
|
||||
return Err(TceError::Decision);
|
||||
}
|
||||
validate_identity(&o.responder).map_err(|_| TceError::Protocol("response: responder".into()))?;
|
||||
if o.nonce.len() != crate::tce::NONCE_SIZE {
|
||||
return Err(TceError::FieldSize);
|
||||
}
|
||||
let mut e = Encoder::new();
|
||||
e.header(crate::tce::TAG_APPROVAL_RESPONSE);
|
||||
e.fixed_bytes(&o.request_hash, crate::tce::HASH_SIZE);
|
||||
e.identity(&o.responder);
|
||||
e.uvarint(o.decision.as_u64());
|
||||
e.timestamp(o.created_at, false);
|
||||
e.fixed_bytes(&o.nonce, crate::tce::NONCE_SIZE);
|
||||
e.check_size(MAX_RESPONSE_TCE);
|
||||
e.bytes()
|
||||
}
|
||||
|
||||
pub fn decode_approval_response(b: &[u8]) -> Result<ApprovalResponse, TceError> {
|
||||
if b.len() > MAX_RESPONSE_TCE {
|
||||
return Err(TceError::ObjectTooLarge);
|
||||
}
|
||||
let mut d = Decoder::new(b);
|
||||
let tag = d.header()?;
|
||||
if tag != crate::tce::TAG_APPROVAL_RESPONSE {
|
||||
return Err(TceError::WrongObject);
|
||||
}
|
||||
let hash_b = d.fixed_bytes(crate::tce::HASH_SIZE)?;
|
||||
let mut request_hash = [0u8; crate::tce::HASH_SIZE];
|
||||
request_hash.copy_from_slice(hash_b);
|
||||
let responder = d.identity()?;
|
||||
validate_identity(&responder).map_err(|_| TceError::Protocol("response: responder".into()))?;
|
||||
let decision = Decision::from_u64(d.uvarint()?).ok_or(TceError::Decision)?;
|
||||
let created_at = d.timestamp(false)?;
|
||||
let nonce_b = d.fixed_bytes(crate::tce::NONCE_SIZE)?;
|
||||
check_end(&d)?;
|
||||
let mut nonce = [0u8; crate::tce::NONCE_SIZE];
|
||||
nonce.copy_from_slice(nonce_b);
|
||||
Ok(ApprovalResponse { request_hash, responder, decision, created_at, nonce })
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// AuthAssertion (0x06)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[derive(Debug, Clone, Default, PartialEq)]
|
||||
pub struct AuthAssertion {
|
||||
pub pubkey: [u8; crate::tce::PUBKEY_SIZE],
|
||||
pub challenge: [u8; crate::tce::CHALLENGE_SIZE],
|
||||
pub scope: String,
|
||||
pub audience: String,
|
||||
pub created_at: u64,
|
||||
}
|
||||
|
||||
pub fn encode_auth_assertion(o: &AuthAssertion) -> Result<Vec<u8>, TceError> {
|
||||
validate_identity(&o.pubkey).map_err(|_| TceError::Protocol("auth: identity".into()))?;
|
||||
if o.challenge.len() != crate::tce::CHALLENGE_SIZE {
|
||||
return Err(TceError::FieldSize);
|
||||
}
|
||||
let mut e = Encoder::new();
|
||||
e.header(crate::tce::TAG_AUTH_ASSERTION);
|
||||
e.identity(&o.pubkey);
|
||||
e.fixed_bytes(&o.challenge, crate::tce::CHALLENGE_SIZE);
|
||||
e.string(&o.scope, MAX_SCOPE_LEN);
|
||||
e.string(&o.audience, MAX_AUDIENCE_LEN);
|
||||
e.timestamp(o.created_at, false);
|
||||
e.check_size(MAX_AUTH_TCE);
|
||||
e.bytes()
|
||||
}
|
||||
|
||||
pub fn decode_auth_assertion(b: &[u8]) -> Result<AuthAssertion, TceError> {
|
||||
if b.len() > MAX_AUTH_TCE {
|
||||
return Err(TceError::ObjectTooLarge);
|
||||
}
|
||||
let mut d = Decoder::new(b);
|
||||
let tag = d.header()?;
|
||||
if tag != crate::tce::TAG_AUTH_ASSERTION {
|
||||
return Err(TceError::WrongObject);
|
||||
}
|
||||
let pubkey = d.identity()?;
|
||||
validate_identity(&pubkey).map_err(|_| TceError::Protocol("auth: identity".into()))?;
|
||||
let chal_b = d.fixed_bytes(crate::tce::CHALLENGE_SIZE)?;
|
||||
let mut challenge = [0u8; crate::tce::CHALLENGE_SIZE];
|
||||
challenge.copy_from_slice(chal_b);
|
||||
let scope = d.string(MAX_SCOPE_LEN)?;
|
||||
let audience = d.string(MAX_AUDIENCE_LEN)?;
|
||||
let created_at = d.timestamp(false)?;
|
||||
check_end(&d)?;
|
||||
Ok(AuthAssertion { pubkey, challenge, scope, audience, created_at })
|
||||
}
|
||||
|
||||
/// Lowercase-hex object id of TCE bytes.
|
||||
pub fn object_id_of(tce: &[u8]) -> String {
|
||||
hex::encode(sha256(tce))
|
||||
}
|
||||
442
src/relay.rs
Normal file
442
src/relay.rs
Normal file
|
|
@ -0,0 +1,442 @@
|
|||
//! Relay client: the AuthAssertion handshake, reading an approval inbox and
|
||||
//! submitting ApprovalResponses over the JSON transport (docs/API.md).
|
||||
//!
|
||||
//! The server never verifies signatures on ingest (`POST /v1/objects` stores
|
||||
//! any well-formed envelope), so every object this module *reads* is strictly
|
||||
//! decoded and verified locally before it is shown to the user. Nothing here
|
||||
//! ever re-encodes the `object` JSON view and verifies that; the verified
|
||||
//! bytes are the received `tce` bytes.
|
||||
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
use base64::Engine as _;
|
||||
use ureq::Body;
|
||||
|
||||
use crate::address::{validate_pubkey, Address};
|
||||
use crate::protocol::{
|
||||
decode_approval_request, decode_approval_response, encode_approval_response, object_id_of,
|
||||
ApprovalRequest, ApprovalResponse, AuthAssertion, Decision,
|
||||
};
|
||||
use crate::signer::{verify, Signer};
|
||||
use crate::tce::{CHALLENGE_SIZE, NONCE_SIZE};
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum RelayError {
|
||||
/// Transport or HTTP-level failure (connection, timeout, bad status).
|
||||
Http(String),
|
||||
/// The relay returned an unexpected shape (missing field, bad base64...).
|
||||
Malformed(String),
|
||||
/// An incoming object failed strict verification.
|
||||
Verify(String),
|
||||
/// Local signing/encoding error.
|
||||
Build(String),
|
||||
}
|
||||
|
||||
impl std::fmt::Display for RelayError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
RelayError::Http(m) => write!(f, "relay http: {m}"),
|
||||
RelayError::Malformed(m) => write!(f, "relay malformed: {m}"),
|
||||
RelayError::Verify(m) => write!(f, "relay verify: {m}"),
|
||||
RelayError::Build(m) => write!(f, "relay build: {m}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for RelayError {}
|
||||
|
||||
fn now_secs() -> u64 {
|
||||
SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map(|d| d.as_secs())
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
fn b64_encode(b: &[u8]) -> String {
|
||||
base64::engine::general_purpose::STANDARD.encode(b)
|
||||
}
|
||||
|
||||
fn b64_decode(s: &str) -> Result<Vec<u8>, RelayError> {
|
||||
base64::engine::general_purpose::STANDARD
|
||||
.decode(s)
|
||||
.map_err(|e| RelayError::Malformed(format!("base64: {e}")))
|
||||
}
|
||||
|
||||
fn agent() -> ureq::Agent {
|
||||
ureq::Agent::new_with_config(
|
||||
ureq::config::Config::builder().http_status_as_error(false).build(),
|
||||
)
|
||||
}
|
||||
|
||||
/// Body read helpers: ureq 3 returns `http::Response<Body>`; the body APIs
|
||||
/// borrow (`&mut self`), so both helpers take the response by value, split it,
|
||||
/// and return it for chained status handling.
|
||||
fn body_text(body: &mut Body) -> String {
|
||||
body.read_to_string().unwrap_or_default()
|
||||
}
|
||||
|
||||
fn body_json<T: serde::de::DeserializeOwned>(body: &mut Body) -> Result<T, RelayError> {
|
||||
body.read_json::<T>().map_err(|e| RelayError::Malformed(format!("json: {e}")))
|
||||
}
|
||||
|
||||
/// One signed object as carried by the wire (envelope).
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Envelope {
|
||||
pub tce: Vec<u8>,
|
||||
pub signature: [u8; 64],
|
||||
}
|
||||
|
||||
/// A verified, ready-to-show approval request.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct IncomingRequest {
|
||||
pub request: ApprovalRequest,
|
||||
/// The exact request TCE bytes as received. A response must hash *these*
|
||||
/// bytes (re-encoding the decoded struct is not byte-exact).
|
||||
pub tce: Vec<u8>,
|
||||
/// request_id = SHA-256(exact request tce), the `request_hash` to answer.
|
||||
pub request_id: [u8; 32],
|
||||
/// Sender address, for display next to the message.
|
||||
pub sender: Address,
|
||||
}
|
||||
|
||||
/// A verified approval response, as read by the requesting service.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct IncomingResponse {
|
||||
pub response: ApprovalResponse,
|
||||
/// The exact response TCE bytes as received.
|
||||
pub tce: Vec<u8>,
|
||||
/// Responder address, for display.
|
||||
pub responder: Address,
|
||||
}
|
||||
|
||||
fn parse_envelope(v: &serde_json::Value) -> Result<Envelope, RelayError> {
|
||||
let tce = b64_decode(
|
||||
v.get("tce")
|
||||
.and_then(|x| x.as_str())
|
||||
.ok_or_else(|| RelayError::Malformed("missing tce".into()))?,
|
||||
)?;
|
||||
let sig_b = b64_decode(
|
||||
v.get("signature")
|
||||
.and_then(|x| x.as_str())
|
||||
.ok_or_else(|| RelayError::Malformed("missing signature".into()))?,
|
||||
)?;
|
||||
let signature: [u8; 64] = sig_b
|
||||
.try_into()
|
||||
.map_err(|_| RelayError::Malformed("signature not 64 bytes".into()))?;
|
||||
Ok(Envelope { tce, signature })
|
||||
}
|
||||
|
||||
/// Responder + signature verification for one listed response envelope.
|
||||
fn verify_incoming_response(item: &serde_json::Value) -> Result<IncomingResponse, RelayError> {
|
||||
let e = parse_envelope(item)?;
|
||||
let response = decode_approval_response(&e.tce)
|
||||
.map_err(|err| RelayError::Verify(format!("decode: {err}")))?;
|
||||
|
||||
validate_pubkey(&response.responder)
|
||||
.map_err(|err| RelayError::Verify(format!("responder key: {err}")))?;
|
||||
verify(&response.responder, &e.tce, &e.signature)
|
||||
.map_err(|err| RelayError::Verify(format!("signature: {err}")))?;
|
||||
|
||||
let responder = Address::from_pubkey(&response.responder)
|
||||
.map_err(|err| RelayError::Verify(format!("responder address: {err}")))?;
|
||||
|
||||
Ok(IncomingResponse { response, tce: e.tce, responder })
|
||||
}
|
||||
|
||||
/// Client for one relay endpoint.
|
||||
pub struct Relay {
|
||||
base: String,
|
||||
agent: ureq::Agent,
|
||||
audience: String,
|
||||
token: String,
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
struct AssertResponse {
|
||||
session_token: String,
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
struct ChallengeResponse {
|
||||
challenge: String,
|
||||
}
|
||||
|
||||
#[allow(clippy::needless_pass_by_value)]
|
||||
fn build_auth_assertion(
|
||||
signer: &Signer,
|
||||
challenge: &[u8; CHALLENGE_SIZE],
|
||||
audience: &str,
|
||||
scope: &str,
|
||||
) -> Result<(Vec<u8>, [u8; 64]), RelayError> {
|
||||
let now = now_secs();
|
||||
let a = AuthAssertion {
|
||||
pubkey: signer.pubkey(),
|
||||
challenge: *challenge,
|
||||
scope: scope.to_string(),
|
||||
audience: audience.to_string(),
|
||||
created_at: now,
|
||||
};
|
||||
let tce = crate::protocol::encode_auth_assertion(&a)
|
||||
.map_err(|e| RelayError::Build(format!("encode auth: {e}")))?;
|
||||
let sig = signer.sign(&tce);
|
||||
Ok((tce, sig))
|
||||
}
|
||||
|
||||
fn post_envelope(
|
||||
agent: &ureq::Agent,
|
||||
url: &str,
|
||||
tce: &[u8],
|
||||
sig: &[u8; 64],
|
||||
) -> Result<serde_json::Value, RelayError> {
|
||||
let body = serde_json::json!({
|
||||
"tce": b64_encode(tce),
|
||||
"signature": b64_encode(sig),
|
||||
});
|
||||
let mut resp = agent
|
||||
.post(url)
|
||||
.send_json(body)
|
||||
.map_err(|e| RelayError::Http(format!("{e}")))?;
|
||||
let status = resp.status().as_u16();
|
||||
if status != 200 {
|
||||
let text = body_text(resp.body_mut());
|
||||
return Err(RelayError::Http(format!("status {status}: {text}")));
|
||||
}
|
||||
body_json(resp.body_mut())
|
||||
}
|
||||
|
||||
fn parse_client_err(raw: ureq::Error) -> RelayError {
|
||||
RelayError::Http(raw.to_string())
|
||||
}
|
||||
|
||||
impl Relay {
|
||||
/// Fetches the server's audience and performs the challenge/assert
|
||||
/// handshake, returning an authenticated client bound to `base`.
|
||||
pub fn auth(base: &str, signer: &Signer, scope: &str) -> Result<Relay, RelayError> {
|
||||
let base = base.trim_end_matches('/').to_string();
|
||||
let agent = agent();
|
||||
|
||||
// 1. audience
|
||||
let mut cfg = agent.get(format!("{base}/v1/config")).call().map_err(parse_client_err)?;
|
||||
let audience = {
|
||||
let v: serde_json::Value = body_json(cfg.body_mut())?;
|
||||
v.get("audience")
|
||||
.and_then(|x| x.as_str())
|
||||
.ok_or_else(|| RelayError::Malformed("config: missing audience".into()))?
|
||||
.to_string()
|
||||
};
|
||||
if audience.is_empty() {
|
||||
return Err(RelayError::Malformed("config: empty audience".into()));
|
||||
}
|
||||
|
||||
// 2. challenge
|
||||
let mut chal = agent
|
||||
.post(format!("{base}/v1/auth/challenge"))
|
||||
.send_empty()
|
||||
.map_err(parse_client_err)?;
|
||||
let ch: ChallengeResponse = body_json(chal.body_mut())?;
|
||||
let ch_b = hex::decode(&ch.challenge)
|
||||
.map_err(|e| RelayError::Malformed(format!("challenge hex: {e}")))?;
|
||||
let challenge: [u8; CHALLENGE_SIZE] = ch_b
|
||||
.try_into()
|
||||
.map_err(|_| RelayError::Malformed("challenge not 32 bytes".into()))?;
|
||||
|
||||
// 3. assert
|
||||
let (a_tce, a_sig) = build_auth_assertion(signer, &challenge, &audience, scope)?;
|
||||
let out = post_envelope(&agent, &format!("{base}/v1/auth/assert"), &a_tce, &a_sig)?;
|
||||
let ar: AssertResponse = serde_json::from_value(out)
|
||||
.map_err(|e| RelayError::Malformed(format!("assert json: {e}")))?;
|
||||
if ar.session_token.is_empty() {
|
||||
return Err(RelayError::Malformed("assert: empty session_token".into()));
|
||||
}
|
||||
|
||||
Ok(Relay { base, agent, audience, token: ar.session_token })
|
||||
}
|
||||
|
||||
pub fn audience(&self) -> &str {
|
||||
&self.audience
|
||||
}
|
||||
|
||||
/// The relay this client is bound to (trailing slash trimmed).
|
||||
pub fn base_url(&self) -> &str {
|
||||
&self.base
|
||||
}
|
||||
|
||||
/// Fetches approval requests addressed to `signer`, strict-decodes and
|
||||
/// verifies each, returning the ones that pass.
|
||||
///
|
||||
/// A single poisoned envelope is skipped, not fatal: the relay can store
|
||||
/// anything, so a client must never trust the feed blindly.
|
||||
pub fn fetch_requests(&self, signer: &Signer) -> Result<Vec<IncomingRequest>, RelayError> {
|
||||
let addr = signer
|
||||
.address()
|
||||
.map_err(|e| RelayError::Build(format!("address: {e}")))?;
|
||||
let url = format!("{}/v1/requests?recipient={}", self.base, addr);
|
||||
let mut resp = self
|
||||
.agent
|
||||
.get(&url)
|
||||
.header("Authorization", format!("Bearer {}", self.token))
|
||||
.call()
|
||||
.map_err(parse_client_err)?;
|
||||
let status = resp.status().as_u16();
|
||||
if status != 200 {
|
||||
let text = body_text(resp.body_mut());
|
||||
return Err(RelayError::Http(format!("status {status}: {text}")));
|
||||
}
|
||||
let v: serde_json::Value = body_json(resp.body_mut())?;
|
||||
let list = v
|
||||
.get("requests")
|
||||
.and_then(|x| x.as_array())
|
||||
.ok_or_else(|| RelayError::Malformed("requests: missing list".into()))?;
|
||||
|
||||
let mut out = Vec::new();
|
||||
for item in list {
|
||||
match self.verify_incoming_request(signer, item) {
|
||||
Ok(r) => out.push(r),
|
||||
Err(e) => eprintln!("relay: skipping bad request envelope: {e}"),
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
// Recipient + sender verification, then builds an IncomingRequest.
|
||||
fn verify_incoming_request(
|
||||
&self,
|
||||
me: &Signer,
|
||||
item: &serde_json::Value,
|
||||
) -> Result<IncomingRequest, RelayError> {
|
||||
let e = parse_envelope(item)?;
|
||||
let request = decode_approval_request(&e.tce)
|
||||
.map_err(|e| RelayError::Verify(format!("decode: {e}")))?;
|
||||
|
||||
// The request must target my key (not just my address text).
|
||||
if request.recipient != me.pubkey() {
|
||||
return Err(RelayError::Verify("recipient mismatch".into()));
|
||||
}
|
||||
// Same verification steps as the reference verifier, in order.
|
||||
validate_pubkey(&request.sender)
|
||||
.map_err(|e| RelayError::Verify(format!("sender key: {e}")))?;
|
||||
verify(&request.sender, &e.tce, &e.signature)
|
||||
.map_err(|e| RelayError::Verify(format!("signature: {e}")))?;
|
||||
|
||||
let sender = Address::from_pubkey(&request.sender)
|
||||
.map_err(|e| RelayError::Verify(format!("sender address: {e}")))?;
|
||||
|
||||
let request_id = object_hash(&e.tce);
|
||||
|
||||
Ok(IncomingRequest { request, tce: e.tce, request_id, sender })
|
||||
}
|
||||
|
||||
/// Fetches ApprovalResponses for one request (by id hex), strict-decodes
|
||||
/// and verifies each. This is the service side of the flow: after storing
|
||||
/// a request, poll this until the recipient answers.
|
||||
pub fn fetch_responses(&self, request_id_hex: &str) -> Result<Vec<IncomingResponse>, RelayError> {
|
||||
let url = format!("{}/v1/responses?request={}", self.base, request_id_hex);
|
||||
let mut resp = self
|
||||
.agent
|
||||
.get(&url)
|
||||
.header("Authorization", format!("Bearer {}", self.token))
|
||||
.call()
|
||||
.map_err(parse_client_err)?;
|
||||
let status = resp.status().as_u16();
|
||||
if status != 200 {
|
||||
let text = body_text(resp.body_mut());
|
||||
return Err(RelayError::Http(format!("status {status}: {text}")));
|
||||
}
|
||||
let v: serde_json::Value = body_json(resp.body_mut())?;
|
||||
let list = v
|
||||
.get("responses")
|
||||
.and_then(|x| x.as_array())
|
||||
.ok_or_else(|| RelayError::Malformed("responses: missing list".into()))?;
|
||||
|
||||
let mut out = Vec::new();
|
||||
for item in list {
|
||||
match verify_incoming_response(item) {
|
||||
Ok(r) => out.push(r),
|
||||
Err(e) => eprintln!("relay: skipping bad response envelope: {e}"),
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Stores an arbitrary signed object, returning its object_id.
|
||||
pub fn store(&self, tce: &[u8], sig: &[u8; 64]) -> Result<String, RelayError> {
|
||||
let out = post_envelope(&self.agent, &format!("{}/v1/objects", self.base), tce, sig)?;
|
||||
out.get("object_id")
|
||||
.and_then(|x| x.as_str())
|
||||
.map(|s| s.to_string())
|
||||
.ok_or_else(|| RelayError::Malformed("objects: missing object_id".into()))
|
||||
}
|
||||
|
||||
/// Builds, signs and submits an ApprovalResponse for `req`.
|
||||
pub fn respond(
|
||||
&self,
|
||||
signer: &Signer,
|
||||
req: &ApprovalRequest,
|
||||
req_tce: &[u8],
|
||||
decision: Decision,
|
||||
) -> Result<String, RelayError> {
|
||||
let now = now_secs();
|
||||
// §8.5.3: response.created_at must fall inside the request window with
|
||||
// the ±120s clock-skew allowance of §13.1. A stale request is refused
|
||||
// locally before we ever submit an answer.
|
||||
if !response_in_window(req, now) {
|
||||
return Err(RelayError::Build("request window has lapsed".into()));
|
||||
}
|
||||
let request_hash = object_hash(req_tce);
|
||||
let mut nonce = [0u8; NONCE_SIZE];
|
||||
getrandom::fill(&mut nonce).map_err(|e| RelayError::Build(format!("rng: {e}")))?;
|
||||
|
||||
let resp = ApprovalResponse {
|
||||
request_hash,
|
||||
responder: signer.pubkey(),
|
||||
decision,
|
||||
created_at: now,
|
||||
nonce,
|
||||
};
|
||||
let tce = encode_approval_response(&resp)
|
||||
.map_err(|e| RelayError::Build(format!("encode response: {e}")))?;
|
||||
let sig = signer.sign(&tce);
|
||||
|
||||
let out = post_envelope(&self.agent, &format!("{}/v1/objects", self.base), &tce, &sig)?;
|
||||
out.get("object_id")
|
||||
.and_then(|x| x.as_str())
|
||||
.map(|s| s.to_string())
|
||||
.ok_or_else(|| RelayError::Malformed("objects: missing object_id".into()))
|
||||
}
|
||||
}
|
||||
|
||||
/// SHA-256 of the exact request TCE bytes (the `request_hash` field).
|
||||
pub fn object_hash(tce: &[u8]) -> [u8; 32] {
|
||||
crate::crypto::sha256(tce)
|
||||
}
|
||||
|
||||
/// PROTOCOL.md §8.5 check 3: `request.created_at - skew <= now <=
|
||||
/// request.expires_at + skew`, with the §13.1 allowance.
|
||||
fn response_in_window(req: &ApprovalRequest, now: u64) -> bool {
|
||||
const SKEW: u64 = crate::protocol::MAX_CLOCK_SKEW;
|
||||
if now < req.created_at {
|
||||
req.created_at - now <= SKEW
|
||||
} else if now > req.expires_at {
|
||||
now - req.expires_at <= SKEW
|
||||
} else {
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
/// Lowcase-hex object id, exposed for logging only; the request_hash compared
|
||||
/// at verify-time is the raw 32 bytes (INV-4).
|
||||
pub fn request_id_hex(tce: &[u8]) -> String {
|
||||
object_id_of(tce)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn object_hash_is_sha256() {
|
||||
let data = b"trust.n1ko.dev/tce/1\x00";
|
||||
let want = crate::crypto::sha256(data);
|
||||
assert_eq!(object_hash(data), want);
|
||||
}
|
||||
}
|
||||
79
src/signer.rs
Normal file
79
src/signer.rs
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
//! Ed25519 signer/verifier. The only place a private key lives on the client.
|
||||
|
||||
use ed25519_dalek::{Signature, Signer as _, SigningKey, VerifyingKey};
|
||||
|
||||
use crate::address::{validate_pubkey, Address, AddressError};
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Signer {
|
||||
signing: SigningKey,
|
||||
key: [u8; 32],
|
||||
}
|
||||
|
||||
impl Signer {
|
||||
/// Reconstructs a signer from a 32-byte seed.
|
||||
pub fn from_seed(seed: [u8; 32]) -> Result<Signer, AddressError> {
|
||||
let signing = SigningKey::from_bytes(&seed);
|
||||
let key = signing.verifying_key().to_bytes();
|
||||
validate_pubkey(&key)?;
|
||||
Ok(Signer { signing, key })
|
||||
}
|
||||
|
||||
/// Generates a fresh identity from the OS CSPRNG.
|
||||
pub fn generate() -> Result<Signer, AddressError> {
|
||||
let mut seed = [0u8; 32];
|
||||
getrandom::fill(&mut seed).map_err(|_| AddressError::KeySize)?;
|
||||
Signer::from_seed(seed)
|
||||
}
|
||||
|
||||
pub fn pubkey(&self) -> [u8; 32] {
|
||||
self.key
|
||||
}
|
||||
|
||||
pub fn address(&self) -> Result<Address, AddressError> {
|
||||
Address::from_pubkey(&self.key)
|
||||
}
|
||||
|
||||
pub fn seed(&self) -> [u8; 32] {
|
||||
self.signing.to_bytes()
|
||||
}
|
||||
|
||||
/// Signs canonical TCE bytes.
|
||||
pub fn sign(&self, msg: &[u8]) -> [u8; 64] {
|
||||
self.signing.sign(msg).to_bytes()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum VerifyError {
|
||||
/// The public key itself is unusable.
|
||||
InvalidPublicKey(AddressError),
|
||||
/// The signature does not verify over the message.
|
||||
InvalidSignature,
|
||||
}
|
||||
|
||||
impl std::fmt::Display for VerifyError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
VerifyError::InvalidPublicKey(e) => write!(f, "invalid public key: {e}"),
|
||||
VerifyError::InvalidSignature => f.write_str("signature does not verify"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Verifies an Ed25519 signature (strict) over `msg` against `pubkey`.
|
||||
///
|
||||
/// `verify_strict` additionally rejects scalar-malleability and torsion-group
|
||||
/// malleability, matching the protocol's RFC 8032 requirement. The protocol
|
||||
/// also demands the key be validated (canonical, prime-order) before any
|
||||
/// signature check; callers should pass keys that passed
|
||||
/// `validate_pubkey`, but a small-order key is rejected here too.
|
||||
pub fn verify(pubkey: &[u8; 32], msg: &[u8], sig: &[u8; 64]) -> Result<(), VerifyError> {
|
||||
let vk = VerifyingKey::from_bytes(pubkey)
|
||||
.map_err(|_| VerifyError::InvalidPublicKey(AddressError::KeyNotOnCurve))?;
|
||||
if vk.is_weak() {
|
||||
return Err(VerifyError::InvalidPublicKey(AddressError::KeySmallOrder));
|
||||
}
|
||||
let sig = Signature::from_bytes(sig);
|
||||
vk.verify_strict(msg, &sig).map_err(|_| VerifyError::InvalidSignature)
|
||||
}
|
||||
202
src/tce/decode.rs
Normal file
202
src/tce/decode.rs
Normal file
|
|
@ -0,0 +1,202 @@
|
|||
//! Strict TCE decoder. Accepts only the exact byte string the encoder would
|
||||
//! produce and rejects everything else — never skips unknown fields.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use super::number::{is_canonical_number, canonical_number};
|
||||
use super::value::Value;
|
||||
use super::{
|
||||
validate_key, validate_string, validate_timestamp, TceError, MAGIC, MAGIC_LEN,
|
||||
ADDRESS_VERSION, MAX_KEY_LEN, MAX_MAP_ENTRIES, MAX_STRING_VALUE, MAX_UVARINT_BYTES,
|
||||
PUBKEY_SIZE, VAL_FALSE, VAL_NULL, VAL_NUMBER, VAL_STRING, VAL_TRUE, VERSION,
|
||||
};
|
||||
|
||||
pub struct Decoder<'a> {
|
||||
buf: &'a [u8],
|
||||
off: usize,
|
||||
}
|
||||
|
||||
impl<'a> Decoder<'a> {
|
||||
pub fn new(buf: &'a [u8]) -> Self {
|
||||
Self { buf, off: 0 }
|
||||
}
|
||||
|
||||
pub fn remaining(&self) -> usize {
|
||||
self.buf.len() - self.off
|
||||
}
|
||||
|
||||
/// Asserts the input is fully consumed; trailing bytes are an error.
|
||||
pub fn end(&self) -> Result<(), TceError> {
|
||||
if self.off != self.buf.len() {
|
||||
return Err(TceError::Trailing);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Reads and validates magic, object tag, version.
|
||||
pub fn header(&mut self) -> Result<u8, TceError> {
|
||||
if self.remaining() < MAGIC_LEN {
|
||||
return Err(TceError::Truncated);
|
||||
}
|
||||
if &self.buf[self.off..self.off + MAGIC_LEN] != MAGIC {
|
||||
return Err(TceError::Magic);
|
||||
}
|
||||
self.off += MAGIC_LEN;
|
||||
|
||||
if self.remaining() < 1 {
|
||||
return Err(TceError::Truncated);
|
||||
}
|
||||
let tag = self.buf[self.off];
|
||||
self.off += 1;
|
||||
if !(1..=6).contains(&tag) {
|
||||
return Err(TceError::ObjectTag);
|
||||
}
|
||||
|
||||
let ver = self.uvarint()?;
|
||||
if ver != VERSION {
|
||||
return Err(TceError::Version);
|
||||
}
|
||||
Ok(tag)
|
||||
}
|
||||
|
||||
/// Canonical LEB128 uvarint; non-minimal and oversized forms are rejected.
|
||||
pub fn uvarint(&mut self) -> Result<u64, TceError> {
|
||||
let mut n: u64 = 0;
|
||||
let mut shift: u32 = 0;
|
||||
let start = self.off;
|
||||
loop {
|
||||
if self.off >= self.buf.len() {
|
||||
return Err(TceError::Truncated);
|
||||
}
|
||||
if self.off - start >= MAX_UVARINT_BYTES {
|
||||
return Err(TceError::Uvarint);
|
||||
}
|
||||
let b = self.buf[self.off];
|
||||
self.off += 1;
|
||||
|
||||
if shift >= 64 || (shift == 63 && b > 1) {
|
||||
return Err(TceError::Overflow);
|
||||
}
|
||||
n |= ((b & 0x7f) as u64) << shift;
|
||||
|
||||
if b & 0x80 == 0 {
|
||||
if self.off - start > 1 && b == 0x00 {
|
||||
return Err(TceError::NonMinimal);
|
||||
}
|
||||
return Ok(n);
|
||||
}
|
||||
shift += 7;
|
||||
}
|
||||
}
|
||||
|
||||
/// Length-prefixed byte string, bounded by `max_len`, checked against
|
||||
/// remaining input before the length is used.
|
||||
pub fn raw_bytes(&mut self, max_len: usize) -> Result<&'a [u8], TceError> {
|
||||
let n = self.uvarint()?;
|
||||
if n > self.remaining() as u64 {
|
||||
return Err(TceError::Truncated);
|
||||
}
|
||||
if n > max_len as u64 {
|
||||
return Err(TceError::TooLong);
|
||||
}
|
||||
let b = &self.buf[self.off..self.off + n as usize];
|
||||
self.off += n as usize;
|
||||
Ok(b)
|
||||
}
|
||||
|
||||
/// Length-prefixed byte string of exactly `want` bytes.
|
||||
pub fn fixed_bytes(&mut self, want: usize) -> Result<&'a [u8], TceError> {
|
||||
let b = self.raw_bytes(want)?;
|
||||
if b.len() != want {
|
||||
return Err(TceError::FieldSize);
|
||||
}
|
||||
Ok(b)
|
||||
}
|
||||
|
||||
/// Length-prefixed validated UTF-8 string.
|
||||
pub fn string(&mut self, max_len: usize) -> Result<String, TceError> {
|
||||
let b = self.raw_bytes(max_len)?;
|
||||
let s = std::str::from_utf8(b).map_err(|_| TceError::Utf8)?;
|
||||
validate_string(s, max_len)?;
|
||||
Ok(s.to_string())
|
||||
}
|
||||
|
||||
/// Identity field: address version + pubkey. Returns a copy.
|
||||
pub fn identity(&mut self) -> Result<[u8; PUBKEY_SIZE], TceError> {
|
||||
let ver = self.uvarint()?;
|
||||
if ver != ADDRESS_VERSION {
|
||||
return Err(TceError::AddressVersion);
|
||||
}
|
||||
let b = self.fixed_bytes(PUBKEY_SIZE)?;
|
||||
let mut out = [0u8; PUBKEY_SIZE];
|
||||
out.copy_from_slice(b);
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
pub fn timestamp(&mut self, allow_zero: bool) -> Result<u64, TceError> {
|
||||
let ts = self.uvarint()?;
|
||||
validate_timestamp(ts, allow_zero)?;
|
||||
Ok(ts)
|
||||
}
|
||||
|
||||
/// Typed value; reserved/unknown tags are rejected, numbers must arrive
|
||||
/// in canonical form.
|
||||
pub fn value(&mut self) -> Result<Value, TceError> {
|
||||
if self.remaining() < 1 {
|
||||
return Err(TceError::Truncated);
|
||||
}
|
||||
let tag = self.buf[self.off];
|
||||
self.off += 1;
|
||||
match tag {
|
||||
VAL_NULL => Ok(Value::Null),
|
||||
VAL_FALSE => Ok(Value::Bool(false)),
|
||||
VAL_TRUE => Ok(Value::Bool(true)),
|
||||
VAL_STRING => {
|
||||
let s = self.string(MAX_STRING_VALUE)?;
|
||||
Ok(Value::Str(s))
|
||||
}
|
||||
VAL_NUMBER => {
|
||||
let b = self.raw_bytes(super::MAX_NUMBER_TOKEN)?;
|
||||
let tok = std::str::from_utf8(b).map_err(|_| TceError::NumberFormat)?;
|
||||
if !is_canonical_number(tok) {
|
||||
return Err(TceError::NumberFormat);
|
||||
}
|
||||
Ok(Value::Number(canonical_number(tok).expect("canonical")))
|
||||
}
|
||||
_ => Err(TceError::ValueTag),
|
||||
}
|
||||
}
|
||||
|
||||
/// Map with ascending-order, uniqueness, grammar and entry-limit checks.
|
||||
pub fn map(&mut self, min_entries: usize) -> Result<BTreeMap<String, Value>, TceError> {
|
||||
let n = self.uvarint()?;
|
||||
if n > MAX_MAP_ENTRIES as u64 {
|
||||
return Err(TceError::TooLong);
|
||||
}
|
||||
if n < min_entries as u64 {
|
||||
return Err(TceError::EmptyMap);
|
||||
}
|
||||
// Each entry costs at least two bytes; refuse an impossible count
|
||||
// before allocating.
|
||||
if n > self.remaining() as u64 {
|
||||
return Err(TceError::Truncated);
|
||||
}
|
||||
let mut m = BTreeMap::new();
|
||||
let mut prev: Option<String> = None;
|
||||
for _ in 0..n {
|
||||
let kb = self.raw_bytes(MAX_KEY_LEN)?;
|
||||
let key = std::str::from_utf8(kb).map_err(|_| TceError::Utf8)?;
|
||||
validate_key(key)?;
|
||||
if prev.as_deref().is_some_and(|p| key == p) {
|
||||
return Err(TceError::DuplicateKey);
|
||||
}
|
||||
if prev.as_deref().is_some_and(|p| key < p) {
|
||||
return Err(TceError::KeyOrder);
|
||||
}
|
||||
let v = self.value()?;
|
||||
m.insert(key.to_string(), v);
|
||||
prev = Some(key.to_string());
|
||||
}
|
||||
Ok(m)
|
||||
}
|
||||
}
|
||||
189
src/tce/encode.rs
Normal file
189
src/tce/encode.rs
Normal file
|
|
@ -0,0 +1,189 @@
|
|||
//! Append-only canonical TCE encoder. Records the first error and then no-ops,
|
||||
//! so a caller can write a straight sequence of field writes and check once.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use super::number::canonical_number;
|
||||
use super::value::Value;
|
||||
use super::{
|
||||
append_uvarint, validate_key, validate_string, validate_timestamp, TceError, MAGIC,
|
||||
PUBKEY_SIZE, VAL_FALSE, VAL_NULL, VAL_NUMBER, VAL_STRING, VAL_TRUE, VERSION,
|
||||
};
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct Encoder {
|
||||
buf: Vec<u8>,
|
||||
err: Option<TceError>,
|
||||
}
|
||||
|
||||
impl Encoder {
|
||||
pub fn new() -> Self {
|
||||
Self { buf: Vec::with_capacity(256), err: None }
|
||||
}
|
||||
|
||||
pub fn err(&self) -> Option<&TceError> {
|
||||
self.err.as_ref()
|
||||
}
|
||||
|
||||
/// Bytes of the object, or the first recorded error.
|
||||
pub fn bytes(&self) -> Result<Vec<u8>, TceError> {
|
||||
match &self.err {
|
||||
Some(e) => Err(e.clone()),
|
||||
None => Ok(self.buf.clone()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Writes magic + object tag + version. Must be the first call.
|
||||
pub fn header(&mut self, tag: u8) {
|
||||
if self.err.is_some() {
|
||||
return;
|
||||
}
|
||||
if !known_object_tag(tag) {
|
||||
self.err = Some(TceError::ObjectTag);
|
||||
return;
|
||||
}
|
||||
self.buf.extend_from_slice(MAGIC);
|
||||
self.buf.push(tag);
|
||||
self.uvarint(VERSION);
|
||||
}
|
||||
|
||||
pub fn uvarint(&mut self, n: u64) {
|
||||
if self.err.is_none() {
|
||||
append_uvarint(&mut self.buf, n);
|
||||
}
|
||||
}
|
||||
|
||||
/// Length-prefixed byte string with no limit of its own.
|
||||
pub fn raw_bytes(&mut self, b: &[u8]) {
|
||||
if self.err.is_some() {
|
||||
return;
|
||||
}
|
||||
append_uvarint(&mut self.buf, b.len() as u64);
|
||||
self.buf.extend_from_slice(b);
|
||||
}
|
||||
|
||||
/// Length-prefixed byte string of exactly `want` bytes.
|
||||
pub fn fixed_bytes(&mut self, b: &[u8], want: usize) {
|
||||
if self.err.is_some() {
|
||||
return;
|
||||
}
|
||||
if b.len() != want {
|
||||
self.err = Some(TceError::FieldSize);
|
||||
return;
|
||||
}
|
||||
self.raw_bytes(b);
|
||||
}
|
||||
|
||||
/// Length-prefixed validated UTF-8 string.
|
||||
pub fn string(&mut self, s: &str, max_len: usize) {
|
||||
if self.err.is_some() {
|
||||
return;
|
||||
}
|
||||
if let Err(e) = validate_string(s, max_len) {
|
||||
self.err = Some(e);
|
||||
return;
|
||||
}
|
||||
self.raw_bytes(s.as_bytes());
|
||||
}
|
||||
|
||||
/// Identity field: address version 0, then length-prefixed raw public key.
|
||||
pub fn identity(&mut self, pubkey: &[u8; PUBKEY_SIZE]) {
|
||||
if self.err.is_some() {
|
||||
return;
|
||||
}
|
||||
self.uvarint(super::ADDRESS_VERSION);
|
||||
self.raw_bytes(pubkey);
|
||||
}
|
||||
|
||||
/// Timestamp with protocol range validation.
|
||||
pub fn timestamp(&mut self, ts: u64, allow_zero: bool) {
|
||||
if self.err.is_some() {
|
||||
return;
|
||||
}
|
||||
if let Err(e) = validate_timestamp(ts, allow_zero) {
|
||||
self.err = Some(e);
|
||||
return;
|
||||
}
|
||||
self.uvarint(ts);
|
||||
}
|
||||
|
||||
/// Typed value: null/false/true have no body; strings and numbers are
|
||||
/// length-prefixed. Numbers are canonicalized here.
|
||||
pub fn value(&mut self, v: &Value) {
|
||||
if self.err.is_some() {
|
||||
return;
|
||||
}
|
||||
match v {
|
||||
Value::Null => self.buf.push(VAL_NULL),
|
||||
Value::Bool(false) => self.buf.push(VAL_FALSE),
|
||||
Value::Bool(true) => self.buf.push(VAL_TRUE),
|
||||
Value::Str(s) => {
|
||||
if let Err(e) = validate_string(s, super::MAX_STRING_VALUE) {
|
||||
self.err = Some(e);
|
||||
return;
|
||||
}
|
||||
self.buf.push(VAL_STRING);
|
||||
self.raw_bytes(s.as_bytes());
|
||||
}
|
||||
Value::Number(tok) => {
|
||||
let canon = match canonical_number(tok) {
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
self.err = Some(e);
|
||||
return;
|
||||
}
|
||||
};
|
||||
self.buf.push(VAL_NUMBER);
|
||||
self.raw_bytes(canon.as_bytes());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Map in canonical (bytewise ascending) order. `min_entries` is enforced.
|
||||
pub fn map(&mut self, m: &BTreeMap<String, Value>, min_entries: usize) {
|
||||
if self.err.is_some() {
|
||||
return;
|
||||
}
|
||||
if m.len() < min_entries {
|
||||
self.err = Some(TceError::EmptyMap);
|
||||
return;
|
||||
}
|
||||
if m.len() > super::MAX_MAP_ENTRIES {
|
||||
self.err = Some(TceError::TooLong);
|
||||
return;
|
||||
}
|
||||
for k in m.keys() {
|
||||
if let Err(e) = validate_key(k) {
|
||||
self.err = Some(e);
|
||||
return;
|
||||
}
|
||||
}
|
||||
self.uvarint(m.len() as u64);
|
||||
for (k, v) in m {
|
||||
self.raw_bytes(k.as_bytes());
|
||||
self.value(v);
|
||||
if self.err.is_some() {
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Applies a whole-object size limit.
|
||||
pub fn check_size(&mut self, limit: usize) {
|
||||
if self.err.is_none() && self.buf.len() > limit {
|
||||
self.err = Some(TceError::ObjectTooLarge);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn known_object_tag(tag: u8) -> bool {
|
||||
matches!(
|
||||
tag,
|
||||
super::TAG_IDENTITY
|
||||
| super::TAG_CLAIM
|
||||
| super::TAG_REVOCATION
|
||||
| super::TAG_APPROVAL_REQUEST
|
||||
| super::TAG_APPROVAL_RESPONSE
|
||||
| super::TAG_AUTH_ASSERTION
|
||||
)
|
||||
}
|
||||
237
src/tce/mod.rs
Normal file
237
src/tce/mod.rs
Normal file
|
|
@ -0,0 +1,237 @@
|
|||
//! TCE — Trust Canonical Encoding, version 1.
|
||||
//!
|
||||
//! Byte-exact port of the normative specification in docs/PROTOCOL.md of the
|
||||
//! niko_trust relay. The frozen vectors in `testdata/vectors/tce_vectors.json`
|
||||
//! are the ground truth this module is checked against; see tests/tce_vectors.rs.
|
||||
|
||||
pub mod decode;
|
||||
pub mod encode;
|
||||
pub mod number;
|
||||
pub mod value;
|
||||
|
||||
pub use value::Value;
|
||||
|
||||
/// Framing magic: `trust.n1ko.dev/tce/1` followed by NUL.
|
||||
pub const MAGIC: &[u8; 21] = b"trust.n1ko.dev/tce/1\x00";
|
||||
pub const MAGIC_LEN: usize = 21;
|
||||
|
||||
/// Object version encoded by this implementation.
|
||||
pub const VERSION: u64 = 1;
|
||||
|
||||
/// Object tags (domain separation, inside signed bytes).
|
||||
pub const TAG_IDENTITY: u8 = 0x01;
|
||||
pub const TAG_CLAIM: u8 = 0x02;
|
||||
pub const TAG_REVOCATION: u8 = 0x03;
|
||||
pub const TAG_APPROVAL_REQUEST: u8 = 0x04;
|
||||
pub const TAG_APPROVAL_RESPONSE: u8 = 0x05;
|
||||
pub const TAG_AUTH_ASSERTION: u8 = 0x06;
|
||||
|
||||
/// Value tags.
|
||||
pub const VAL_NULL: u8 = 0x00;
|
||||
pub const VAL_FALSE: u8 = 0x01;
|
||||
pub const VAL_TRUE: u8 = 0x02;
|
||||
pub const VAL_STRING: u8 = 0x03;
|
||||
pub const VAL_NUMBER: u8 = 0x04;
|
||||
|
||||
/// Fixed sizes.
|
||||
pub const ADDRESS_VERSION: u64 = 0;
|
||||
pub const PUBKEY_SIZE: usize = 32;
|
||||
pub const NONCE_SIZE: usize = 16;
|
||||
pub const HASH_SIZE: usize = 32;
|
||||
pub const CHALLENGE_SIZE: usize = 32;
|
||||
pub const SIGNATURE_SIZE: usize = 64;
|
||||
|
||||
/// Field limits (PROTOCOL.md section 6.3).
|
||||
pub const MAX_UVARINT_BYTES: usize = 10;
|
||||
pub const MAX_KEY_LEN: usize = 128;
|
||||
pub const MAX_STRING_VALUE: usize = 512;
|
||||
pub const MAX_NUMBER_TOKEN: usize = 52;
|
||||
pub const MAX_MAP_ENTRIES: usize = 32;
|
||||
pub const MAX_ACTION_LEN: usize = 128;
|
||||
pub const MAX_MESSAGE_LEN: usize = 256;
|
||||
pub const MAX_REASON_LEN: usize = 256;
|
||||
pub const MAX_ALIAS_LEN: usize = 64;
|
||||
pub const MAX_SCOPE_LEN: usize = 32;
|
||||
pub const MAX_AUDIENCE_LEN: usize = 128;
|
||||
pub const MAX_NUMBER_INT_DIGS: usize = 32;
|
||||
pub const MAX_NUMBER_FRAC_DIG: usize = 18;
|
||||
pub const MAX_NUMBER_SOURCE: usize = 64;
|
||||
|
||||
/// Whole-object TCE limits.
|
||||
pub const MAX_IDENTITY_TCE: usize = 1024;
|
||||
pub const MAX_CLAIM_TCE: usize = 4096;
|
||||
pub const MAX_REVOC_TCE: usize = 1024;
|
||||
pub const MAX_REQUEST_TCE: usize = 8192;
|
||||
pub const MAX_RESPONSE_TCE: usize = 1024;
|
||||
pub const MAX_AUTH_TCE: usize = 1024;
|
||||
|
||||
/// Timestamp bounds.
|
||||
pub const MIN_TIMESTAMP: u64 = 1_000_000_000;
|
||||
pub const MAX_TIMESTAMP: u64 = 4_102_444_800;
|
||||
|
||||
/// Max lifetime of an ApprovalRequest (seconds).
|
||||
pub const MAX_APPROVAL_LIFETIME: u64 = 60;
|
||||
|
||||
/// Decision values.
|
||||
pub const DECISION_DENY: u64 = 0;
|
||||
pub const DECISION_ALLOW: u64 = 1;
|
||||
|
||||
/// TCE / protocol error set. Mirrors the small, data-free error set of the Go
|
||||
/// reference so a hostile object cannot influence error text.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum TceError {
|
||||
/// Bad framing magic.
|
||||
Magic,
|
||||
/// Unknown object tag.
|
||||
ObjectTag,
|
||||
/// Unsupported object version.
|
||||
Version,
|
||||
/// Input ended mid-field.
|
||||
Truncated,
|
||||
/// Bytes after the last field.
|
||||
Trailing,
|
||||
/// Malformed uvarint (too many bytes / truncated).
|
||||
Uvarint,
|
||||
/// Non-minimal uvarint spelling.
|
||||
NonMinimal,
|
||||
/// Uvarint overflow.
|
||||
Overflow,
|
||||
/// Field exceeds its maximum length.
|
||||
TooLong,
|
||||
/// Whole object exceeds its size limit.
|
||||
ObjectTooLarge,
|
||||
/// Invalid UTF-8.
|
||||
Utf8,
|
||||
/// Control character in a string.
|
||||
ControlChar,
|
||||
/// Map key does not match the key grammar.
|
||||
KeyGrammar,
|
||||
/// Duplicate map key.
|
||||
DuplicateKey,
|
||||
/// Map keys not ascending.
|
||||
KeyOrder,
|
||||
/// Unknown or reserved value tag.
|
||||
ValueTag,
|
||||
/// Malformed or non-canonical number token.
|
||||
NumberFormat,
|
||||
/// Number out of representable range.
|
||||
NumberRange,
|
||||
/// Timestamp out of range.
|
||||
Timestamp,
|
||||
/// Fixed-size field has wrong length.
|
||||
FieldSize,
|
||||
/// Unsupported address version in an identity field.
|
||||
AddressVersion,
|
||||
/// Map requires at least the specified entries.
|
||||
EmptyMap,
|
||||
/// Unknown decision value.
|
||||
Decision,
|
||||
/// Approval lifetime out of bounds.
|
||||
Lifetime,
|
||||
/// expires_at must be after created_at.
|
||||
Expiry,
|
||||
/// Decoded to a different object type than expected.
|
||||
WrongObject,
|
||||
/// Signature has wrong length or does not verify.
|
||||
Signature,
|
||||
/// Other identity errors surfaced as strings.
|
||||
Protocol(String),
|
||||
}
|
||||
|
||||
impl std::fmt::Display for TceError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
let s = match self {
|
||||
TceError::Magic => "tce: bad magic",
|
||||
TceError::ObjectTag => "tce: unknown object tag",
|
||||
TceError::Version => "tce: unsupported object version",
|
||||
TceError::Truncated => "tce: truncated input",
|
||||
TceError::Trailing => "tce: trailing bytes after object",
|
||||
TceError::Uvarint => "tce: malformed uvarint",
|
||||
TceError::NonMinimal => "tce: non-minimal uvarint",
|
||||
TceError::Overflow => "tce: integer overflow",
|
||||
TceError::TooLong => "tce: field exceeds maximum length",
|
||||
TceError::ObjectTooLarge => "tce: object exceeds maximum size",
|
||||
TceError::Utf8 => "tce: invalid UTF-8",
|
||||
TceError::ControlChar => "tce: control character in string",
|
||||
TceError::KeyGrammar => "tce: map key does not match grammar",
|
||||
TceError::DuplicateKey => "tce: duplicate map key",
|
||||
TceError::KeyOrder => "tce: map keys not in ascending order",
|
||||
TceError::ValueTag => "tce: unknown or reserved value tag",
|
||||
TceError::NumberFormat => "tce: not a valid JSON number",
|
||||
TceError::NumberRange => "tce: number out of representable range",
|
||||
TceError::Timestamp => "tce: timestamp out of range",
|
||||
TceError::FieldSize => "tce: fixed-size field has wrong length",
|
||||
TceError::AddressVersion => "tce: unsupported address version",
|
||||
TceError::EmptyMap => "tce: map requires at least one entry",
|
||||
TceError::Decision => "tce: unknown decision value",
|
||||
TceError::Lifetime => "tce: approval lifetime out of bounds",
|
||||
TceError::Expiry => "tce: expires_at must be after created_at",
|
||||
TceError::WrongObject => "protocol: unexpected object type",
|
||||
TceError::Signature => "protocol: signature does not verify",
|
||||
TceError::Protocol(m) => return f.write_str(m),
|
||||
};
|
||||
f.write_str(s)
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for TceError {}
|
||||
|
||||
/// Appends the canonical LEB128 encoding of `n` (shortest form).
|
||||
pub fn append_uvarint(dst: &mut Vec<u8>, mut n: u64) {
|
||||
while n >= 0x80 {
|
||||
dst.push((n as u8) | 0x80);
|
||||
n >>= 7;
|
||||
}
|
||||
dst.push(n as u8);
|
||||
}
|
||||
|
||||
/// Validates a timestamp against protocol bounds. `allow_zero` permits the one
|
||||
/// exception `0` meaning "does not expire".
|
||||
pub fn validate_timestamp(ts: u64, allow_zero: bool) -> Result<(), TceError> {
|
||||
if allow_zero && ts == 0 {
|
||||
return Ok(());
|
||||
}
|
||||
if !(MIN_TIMESTAMP..=MAX_TIMESTAMP).contains(&ts) {
|
||||
return Err(TceError::Timestamp);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Applies the protocol string rules: length bound, valid UTF-8, no controls.
|
||||
pub fn validate_string(s: &str, max_len: usize) -> Result<(), TceError> {
|
||||
if s.len() > max_len {
|
||||
return Err(TceError::TooLong);
|
||||
}
|
||||
for c in s.chars() {
|
||||
if c <= '\u{1f}' || c == '\u{7f}' || ('\u{80}'..='\u{9f}').contains(&c) {
|
||||
return Err(TceError::ControlChar);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Map key grammar: `[a-z][a-z0-9]*([._-][a-z0-9]+)*`.
|
||||
pub fn validate_key(k: &str) -> Result<(), TceError> {
|
||||
let b = k.as_bytes();
|
||||
if b.is_empty() || b.len() > MAX_KEY_LEN {
|
||||
return Err(TceError::TooLong);
|
||||
}
|
||||
if !b[0].is_ascii_lowercase() {
|
||||
return Err(TceError::KeyGrammar);
|
||||
}
|
||||
let mut prev_sep = false;
|
||||
for i in 1..b.len() {
|
||||
let c = b[i];
|
||||
if c.is_ascii_lowercase() || c.is_ascii_digit() {
|
||||
prev_sep = false;
|
||||
} else if c == b'.' || c == b'_' || c == b'-' {
|
||||
if prev_sep || i == b.len() - 1 {
|
||||
return Err(TceError::KeyGrammar);
|
||||
}
|
||||
prev_sep = true;
|
||||
} else {
|
||||
return Err(TceError::KeyGrammar);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
247
src/tce/number.rs
Normal file
247
src/tce/number.rs
Normal file
|
|
@ -0,0 +1,247 @@
|
|||
//! Number canonicalization (PROTOCOL.md section 5.1).
|
||||
//!
|
||||
//! Numbers are carried as decimal text. The canonical form is plain decimal
|
||||
//! with no exponent, no leading zeros, and no trailing fractional zeros.
|
||||
//! Negative zero is not representable and maps to "0". No binary float is ever
|
||||
//! involved.
|
||||
|
||||
use crate::tce::{TceError, MAX_NUMBER_FRAC_DIG, MAX_NUMBER_INT_DIGS, MAX_NUMBER_SOURCE, MAX_NUMBER_TOKEN};
|
||||
|
||||
fn is_digit(c: u8) -> bool {
|
||||
c.is_ascii_digit()
|
||||
}
|
||||
|
||||
fn zeros(n: usize) -> String {
|
||||
if n == 0 {
|
||||
return String::new();
|
||||
}
|
||||
"0".repeat(n)
|
||||
}
|
||||
|
||||
/// Splits a token into (int_part, frac_part, exp_part, negative).
|
||||
fn split_number(s: &str) -> Result<(String, String, String, bool), TceError> {
|
||||
let b = s.as_bytes();
|
||||
let mut i = 0;
|
||||
let negative = if i < b.len() && b[i] == b'-' {
|
||||
i += 1;
|
||||
true
|
||||
} else {
|
||||
false
|
||||
};
|
||||
|
||||
let start = i;
|
||||
if i >= b.len() {
|
||||
return Err(TceError::NumberFormat);
|
||||
}
|
||||
if b[i] == b'0' {
|
||||
i += 1;
|
||||
} else if b[i].is_ascii_digit() && b[i] != b'0' {
|
||||
while i < b.len() && is_digit(b[i]) {
|
||||
i += 1;
|
||||
}
|
||||
} else {
|
||||
return Err(TceError::NumberFormat);
|
||||
}
|
||||
let int_part = &s[start..i];
|
||||
if int_part.len() > 1 && int_part.as_bytes()[0] == b'0' {
|
||||
return Err(TceError::NumberFormat);
|
||||
}
|
||||
|
||||
let mut frac_part = "";
|
||||
if i < b.len() && b[i] == b'.' {
|
||||
i += 1;
|
||||
let fs = i;
|
||||
while i < b.len() && is_digit(b[i]) {
|
||||
i += 1;
|
||||
}
|
||||
if i == fs {
|
||||
return Err(TceError::NumberFormat);
|
||||
}
|
||||
frac_part = &s[fs..i];
|
||||
}
|
||||
|
||||
let mut exp_part = "";
|
||||
if i < b.len() && (b[i] == b'e' || b[i] == b'E') {
|
||||
i += 1;
|
||||
let es = i;
|
||||
if i < b.len() && (b[i] == b'+' || b[i] == b'-') {
|
||||
i += 1;
|
||||
}
|
||||
let ds = i;
|
||||
while i < b.len() && is_digit(b[i]) {
|
||||
i += 1;
|
||||
}
|
||||
if i == ds {
|
||||
return Err(TceError::NumberFormat);
|
||||
}
|
||||
exp_part = &s[es..i];
|
||||
}
|
||||
|
||||
if i != b.len() {
|
||||
return Err(TceError::NumberFormat);
|
||||
}
|
||||
Ok((int_part.to_string(), frac_part.to_string(), exp_part.to_string(), negative))
|
||||
}
|
||||
|
||||
fn trim_leading_zeros(s: &str) -> &str {
|
||||
let mut i = 0;
|
||||
while i < s.len() - 1 && s.as_bytes()[i] == b'0' {
|
||||
i += 1;
|
||||
}
|
||||
&s[i..]
|
||||
}
|
||||
|
||||
/// Reduces a JSON number source token to its unique canonical form.
|
||||
pub fn canonical_number(token: &str) -> Result<String, TceError> {
|
||||
if token.is_empty() || token.len() > MAX_NUMBER_SOURCE {
|
||||
return Err(TceError::NumberFormat);
|
||||
}
|
||||
let (int_part, frac_part, exp_part, negative) = split_number(token)?;
|
||||
|
||||
if exp_part.len() > 4 {
|
||||
return Err(TceError::NumberRange);
|
||||
}
|
||||
let mut exp: i64 = 0;
|
||||
if !exp_part.is_empty() {
|
||||
let mut k = 0;
|
||||
let exp_neg = {
|
||||
let c = exp_part.as_bytes()[0];
|
||||
if c == b'+' || c == b'-' {
|
||||
k = 1;
|
||||
c == b'-'
|
||||
} else {
|
||||
false
|
||||
}
|
||||
};
|
||||
let digits = &exp_part[k..];
|
||||
if digits.is_empty() || digits.len() > 4 {
|
||||
return Err(TceError::NumberRange);
|
||||
}
|
||||
for c in digits.bytes() {
|
||||
exp = exp * 10 + (c - b'0') as i64;
|
||||
}
|
||||
if exp_neg {
|
||||
exp = -exp;
|
||||
}
|
||||
}
|
||||
|
||||
// value = sign * mantissa * 10^scale, mantissa = int_part ++ frac_part.
|
||||
let digits = format!("{}{}", int_part, frac_part);
|
||||
let scale: i64 = exp - frac_part.len() as i64;
|
||||
|
||||
// Strip leading zeros of the mantissa (equivalent to big.Int parse).
|
||||
let mantissa = trim_leading_zeros(&digits);
|
||||
|
||||
if *mantissa == *"0" {
|
||||
return Ok("0".to_string());
|
||||
}
|
||||
|
||||
// Strip trailing zeros while scale < 0 (fractional padding only).
|
||||
let mut ds = mantissa.to_string();
|
||||
let mut scale = scale;
|
||||
while scale < 0 && ds.ends_with('0') {
|
||||
ds.pop();
|
||||
scale += 1;
|
||||
}
|
||||
|
||||
if scale > 0 && ds.len() + scale as usize > MAX_NUMBER_INT_DIGS {
|
||||
return Err(TceError::NumberRange);
|
||||
}
|
||||
if scale < 0 && -scale > MAX_NUMBER_FRAC_DIG as i64 + ds.len() as i64 {
|
||||
return Err(TceError::NumberRange);
|
||||
}
|
||||
|
||||
let (int_digits, frac_digits): (String, String);
|
||||
if scale >= 0 {
|
||||
int_digits = format!("{}{}", ds, zeros(scale as usize));
|
||||
frac_digits = String::new();
|
||||
} else {
|
||||
let point = ds.len() as i64 + scale;
|
||||
if point <= 0 {
|
||||
int_digits = "0".to_string();
|
||||
frac_digits = format!("{}{}", zeros((-point) as usize), ds);
|
||||
} else {
|
||||
int_digits = ds[..point as usize].to_string();
|
||||
frac_digits = ds[point as usize..].to_string();
|
||||
}
|
||||
}
|
||||
|
||||
if trim_leading_zeros(&int_digits).len() > MAX_NUMBER_INT_DIGS {
|
||||
return Err(TceError::NumberRange);
|
||||
}
|
||||
if frac_digits.len() > MAX_NUMBER_FRAC_DIG {
|
||||
return Err(TceError::NumberRange);
|
||||
}
|
||||
|
||||
let mut n = int_digits.len();
|
||||
if negative {
|
||||
n += 1;
|
||||
}
|
||||
if !frac_digits.is_empty() {
|
||||
n += 1 + frac_digits.len();
|
||||
}
|
||||
if n > MAX_NUMBER_TOKEN {
|
||||
return Err(TceError::NumberRange);
|
||||
}
|
||||
|
||||
let mut out = String::with_capacity(n);
|
||||
if negative {
|
||||
out.push('-');
|
||||
}
|
||||
out.push_str(&int_digits);
|
||||
if !frac_digits.is_empty() {
|
||||
out.push('.');
|
||||
out.push_str(&frac_digits);
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Reports whether `token` is already in canonical form (decoder check).
|
||||
pub fn is_canonical_number(token: &str) -> bool {
|
||||
matches!(canonical_number(token), Ok(ref c) if c == token)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn basic_cases() {
|
||||
for (input, want) in [
|
||||
("0", "0"),
|
||||
("-0", "0"),
|
||||
("0.0", "0"),
|
||||
("0e10", "0"),
|
||||
("1", "1"),
|
||||
("1.0", "1"),
|
||||
("1e0", "1"),
|
||||
("1.000", "1"),
|
||||
("1e1", "10"),
|
||||
("1.5", "1.5"),
|
||||
("1.50", "1.5"),
|
||||
("1e-1", "0.1"),
|
||||
("1e-3", "0.001"),
|
||||
("1.23e2", "123"),
|
||||
("1e18", "1000000000000000000"),
|
||||
("-1e-18", "-0.000000000000000001"),
|
||||
("12345678901234567890", "12345678901234567890"),
|
||||
("999999999999999999999999", "999999999999999999999999"),
|
||||
("1E+2", "100"),
|
||||
("-0.0000", "0"),
|
||||
] {
|
||||
assert_eq!(canonical_number(input).unwrap(), want, "input {input}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejected_cases() {
|
||||
for input in ["+1", "01", "1.", ".5", "1e", "0x10", "NaN", "Infinity", "1_000", "1e99999", "1e9999"] {
|
||||
assert!(canonical_number(input).is_err(), "should reject {input}");
|
||||
}
|
||||
// 1 followed by 40 zeros exceeds integer digits.
|
||||
let big = format!("1{}", "0".repeat(40));
|
||||
assert!(canonical_number(&big).is_err());
|
||||
// too many fractional digits
|
||||
assert!(canonical_number("0.0000000000000000011").is_err());
|
||||
}
|
||||
}
|
||||
75
src/tce/value.rs
Normal file
75
src/tce/value.rs
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
//! Typed claim/payload values.
|
||||
|
||||
use crate::tce::number::{canonical_number, is_canonical_number as is_canonical};
|
||||
|
||||
/// A claim or approval payload value. The set of representable values is
|
||||
/// exactly the set the specification defines; there is no way to hold a
|
||||
/// reserved tag.
|
||||
#[derive(Clone, Debug)]
|
||||
pub enum Value {
|
||||
Null,
|
||||
Bool(bool),
|
||||
Str(String),
|
||||
/// Number as its decimal source token. After decode always canonical;
|
||||
/// before encode it is canonicalized by the encoder.
|
||||
Number(String),
|
||||
}
|
||||
|
||||
impl Value {
|
||||
pub fn num(token: impl Into<String>) -> Value {
|
||||
Value::Number(token.into())
|
||||
}
|
||||
|
||||
/// Canonical form token when this is a number.
|
||||
pub fn number_token(&self) -> Option<String> {
|
||||
match self {
|
||||
Value::Number(t) => canonical_number(t).ok(),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl PartialEq for Value {
|
||||
fn eq(&self, other: &Self) -> bool {
|
||||
use Value::*;
|
||||
match (self, other) {
|
||||
(Null, Null) => true,
|
||||
(Bool(a), Bool(b)) => a == b,
|
||||
(Str(a), Str(b)) => a == b,
|
||||
(Number(a), Number(b)) => match (canonical_number(a), canonical_number(b)) {
|
||||
(Ok(x), Ok(y)) => x == y,
|
||||
_ => false,
|
||||
},
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Reads the value tag byte for encoding.
|
||||
#[allow(dead_code)]
|
||||
pub fn tag_of(v: &Value) -> u8 {
|
||||
match v {
|
||||
Value::Null => crate::tce::VAL_NULL,
|
||||
Value::Bool(false) => crate::tce::VAL_FALSE,
|
||||
Value::Bool(true) => crate::tce::VAL_TRUE,
|
||||
Value::Str(_) => crate::tce::VAL_STRING,
|
||||
Value::Number(_) => crate::tce::VAL_NUMBER,
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether a raw value tag byte is a defined, non-reserved tag.
|
||||
pub fn known_tag(tag: u8) -> bool {
|
||||
matches!(
|
||||
tag,
|
||||
crate::tce::VAL_NULL
|
||||
| crate::tce::VAL_FALSE
|
||||
| crate::tce::VAL_TRUE
|
||||
| crate::tce::VAL_STRING
|
||||
| crate::tce::VAL_NUMBER
|
||||
)
|
||||
}
|
||||
|
||||
/// Whether `token` is already in canonical form, for the decoder.
|
||||
pub fn is_canonical_number(token: &str) -> bool {
|
||||
is_canonical(token)
|
||||
}
|
||||
235
src/vault.rs
Normal file
235
src/vault.rs
Normal file
|
|
@ -0,0 +1,235 @@
|
|||
//! Vault: the single portable state file.
|
||||
//!
|
||||
//! Everything the app persists lives in one JSON document,
|
||||
//! `<config_dir>/niko-trust/vault.json` (`~/.config/niko-trust/` on Linux,
|
||||
//! `%APPDATA%\niko-trust\` on Windows). Copy that one file to another device
|
||||
//! and the whole identity, address book, history and settings move with it.
|
||||
//!
|
||||
//! The identity is stored exactly in the keyring `FileFormat` (argon2id +
|
||||
//! XChaCha20-Poly1305), so a vault remains useless without the passphrase.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use crate::inbox::{HistoryItem, Outcome};
|
||||
|
||||
pub const VAULT_VERSION: u8 = 1;
|
||||
|
||||
/// One stored history entry (display-only; responses are never replayed).
|
||||
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
|
||||
pub struct StoredItem {
|
||||
pub id: String, // request_id hex
|
||||
pub sender: String,
|
||||
pub action: String,
|
||||
pub message: String,
|
||||
pub created_at: u64,
|
||||
pub expires_at: u64,
|
||||
/// outcome label, one of pending/approved/denied/expired/stale
|
||||
pub outcome: String,
|
||||
}
|
||||
|
||||
impl From<&HistoryItem> for StoredItem {
|
||||
fn from(h: &HistoryItem) -> Self {
|
||||
StoredItem {
|
||||
id: h.request_id_hex.clone(),
|
||||
sender: h.sender.to_string().to_owned(),
|
||||
action: h.action.clone(),
|
||||
message: h.message.clone(),
|
||||
created_at: h.created_at,
|
||||
expires_at: h.expires_at,
|
||||
outcome: crate::inbox::outcome_label(h.outcome).to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl TryFrom<&StoredItem> for HistoryItem {
|
||||
type Error = ();
|
||||
fn try_from(s: &StoredItem) -> Result<Self, Self::Error> {
|
||||
let sender = crate::address::Address::parse(&s.sender).map_err(|_| ())?;
|
||||
let outcome = match s.outcome.as_str() {
|
||||
"approved" => Outcome::Approved,
|
||||
"denied" => Outcome::Denied,
|
||||
"expired" => Outcome::TimedOut,
|
||||
"stale" => Outcome::Stale,
|
||||
_ => Outcome::Pending,
|
||||
};
|
||||
Ok(HistoryItem {
|
||||
request_id_hex: s.id.clone(),
|
||||
sender,
|
||||
action: s.action.clone(),
|
||||
message: s.message.clone(),
|
||||
created_at: s.created_at,
|
||||
expires_at: s.expires_at,
|
||||
outcome,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, serde::Serialize, serde::Deserialize)]
|
||||
struct VaultData {
|
||||
version: u8,
|
||||
/// Keyring FileFormat (opaque here), present once an identity exists.
|
||||
#[serde(default)]
|
||||
identity: Option<serde_json::Value>,
|
||||
#[serde(default)]
|
||||
address_book: BTreeMap<String, String>,
|
||||
#[serde(default)]
|
||||
history: Vec<StoredItem>,
|
||||
#[serde(default)]
|
||||
relay_url: String,
|
||||
}
|
||||
|
||||
/// Handle over the loaded vault file.
|
||||
#[derive(Debug)]
|
||||
pub struct Vault {
|
||||
path: PathBuf,
|
||||
data: VaultData,
|
||||
}
|
||||
|
||||
/// The directory holding `vault.json` (created if missing).
|
||||
pub fn config_dir() -> PathBuf {
|
||||
let base = dirs::config_dir().unwrap_or_else(|| PathBuf::from("."));
|
||||
base.join("niko-trust")
|
||||
}
|
||||
|
||||
impl Vault {
|
||||
/// Loads `vault.json`; a missing or corrupt file yields defaults. Migrates
|
||||
/// the legacy `identity.json` / `addressbook.json` files when present.
|
||||
pub fn load() -> Vault {
|
||||
let dir = config_dir();
|
||||
let _ = std::fs::create_dir_all(&dir);
|
||||
let path = dir.join("vault.json");
|
||||
let mut data: VaultData = std::fs::read(&path)
|
||||
.ok()
|
||||
.and_then(|b| serde_json::from_slice(&b).ok())
|
||||
.unwrap_or_default();
|
||||
data.version = VAULT_VERSION;
|
||||
|
||||
// Legacy migration: identity written by older builds.
|
||||
if data.identity.is_none() {
|
||||
let legacy = dir.join("identity.json");
|
||||
if let Ok(raw) = std::fs::read_to_string(&legacy) {
|
||||
if let Ok(v) = serde_json::from_str::<serde_json::Value>(&raw) {
|
||||
data.identity = Some(v);
|
||||
}
|
||||
}
|
||||
}
|
||||
// Legacy migration: address book written by older builds.
|
||||
let legacy_book = dir.join("addressbook.json");
|
||||
if data.address_book.is_empty() {
|
||||
if let Ok(raw) = std::fs::read_to_string(&legacy_book) {
|
||||
if let Ok(m) = serde_json::from_str::<BTreeMap<String, String>>(&raw) {
|
||||
data.address_book = m;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let v = Vault { path, data };
|
||||
v.save();
|
||||
v
|
||||
}
|
||||
|
||||
pub fn save(&self) {
|
||||
if let Ok(json) = serde_json::to_vec_pretty(&self.data) {
|
||||
let _ = std::fs::write(&self.path, json);
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let _ = std::fs::set_permissions(
|
||||
&self.path,
|
||||
std::fs::Permissions::from_mode(0o600),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn path(&self) -> &std::path::Path {
|
||||
&self.path
|
||||
}
|
||||
|
||||
// ------------------------------------------------------- identity
|
||||
|
||||
pub fn identity_blob(&self) -> Option<&serde_json::Value> {
|
||||
self.data.identity.as_ref()
|
||||
}
|
||||
|
||||
pub fn set_identity_blob(&mut self, v: serde_json::Value) {
|
||||
self.data.identity = Some(v);
|
||||
self.save();
|
||||
}
|
||||
|
||||
// --------------------------------------------------- address book
|
||||
|
||||
pub fn address_book(&self) -> &BTreeMap<String, String> {
|
||||
&self.data.address_book
|
||||
}
|
||||
|
||||
pub fn set_address_book(&mut self, entries: BTreeMap<String, String>) {
|
||||
self.data.address_book = entries;
|
||||
self.save();
|
||||
}
|
||||
|
||||
// -------------------------------------------------------- history
|
||||
|
||||
pub fn history(&self) -> &[StoredItem] {
|
||||
&self.data.history
|
||||
}
|
||||
|
||||
pub fn set_history(&mut self, items: Vec<StoredItem>) {
|
||||
self.data.history = items;
|
||||
self.save();
|
||||
}
|
||||
|
||||
// ------------------------------------------------------- settings
|
||||
|
||||
pub fn relay_url(&self) -> &str {
|
||||
&self.data.relay_url
|
||||
}
|
||||
|
||||
pub fn set_relay_url(&mut self, url: impl Into<String>) {
|
||||
self.data.relay_url = url.into();
|
||||
self.save();
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn roundtrip_all_sections() {
|
||||
let dir = std::env::temp_dir().join(format!("nk-vault-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
// Can't easily override config_dir(); exercise VaultData directly via
|
||||
// serde by writing and re-reading through a temp file path clone.
|
||||
let mut data = VaultData {
|
||||
version: VAULT_VERSION,
|
||||
..Default::default()
|
||||
};
|
||||
data.relay_url = "https://relay.example".into();
|
||||
let addr = "trust1qz8jrkwdnqscwrvptu69z6ntd2dp092s0fk25ayq94qlzsx649dszg7tx6w";
|
||||
data.address_book.insert(addr.to_string(), "Niko".into());
|
||||
data.history.push(StoredItem {
|
||||
id: "ff".repeat(32),
|
||||
sender: addr.to_string(),
|
||||
action: "2fa".into(),
|
||||
message: "hi".into(),
|
||||
created_at: 1_700_000_000,
|
||||
expires_at: 1_700_000_060,
|
||||
outcome: "approved".into(),
|
||||
});
|
||||
|
||||
let p = dir.join("vault.json");
|
||||
std::fs::write(&p, serde_json::to_vec(&data).unwrap()).unwrap();
|
||||
let back: VaultData = serde_json::from_slice(&std::fs::read(&p).unwrap()).unwrap();
|
||||
assert_eq!(back.relay_url, "https://relay.example");
|
||||
assert_eq!(back.address_book.get(addr).map(String::as_str), Some("Niko"));
|
||||
assert_eq!(back.history.len(), 1);
|
||||
|
||||
let h = HistoryItem::try_from(&back.history[0]).unwrap();
|
||||
assert_eq!(h.outcome, Outcome::Approved);
|
||||
assert_eq!(h.request_id_hex, "ff".repeat(32));
|
||||
|
||||
std::fs::remove_dir_all(dir).ok();
|
||||
}
|
||||
}
|
||||
236
tests/live_relay.rs
Normal file
236
tests/live_relay.rs
Normal file
|
|
@ -0,0 +1,236 @@
|
|||
//! Live integration tests against a real relay instance.
|
||||
//!
|
||||
//! Skips (with a clear message) when `NIKO_RELAY` is not set. The store is
|
||||
//! in-memory per server run, so these tests are self-contained: they create
|
||||
//! their own sender and recipient identities, post a request, verify and
|
||||
//! answer it, and check the response is queryable. Nothing is left behind.
|
||||
|
||||
use niko_trust_gui::inbox::{Inbox, Outcome};
|
||||
use niko_trust_gui::protocol::{encode_approval_request, ApprovalRequest, Decision};
|
||||
use niko_trust_gui::relay::Relay;
|
||||
use niko_trust_gui::signer::Signer;
|
||||
use niko_trust_gui::tce::NONCE_SIZE;
|
||||
|
||||
fn base() -> Option<String> {
|
||||
std::env::var("NIKO_RELAY")
|
||||
.ok()
|
||||
.map(|s| s.trim_end_matches('/').to_string())
|
||||
}
|
||||
|
||||
fn now() -> u64 {
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_secs()
|
||||
}
|
||||
|
||||
fn mk_request(
|
||||
sender: &Signer,
|
||||
recipient: &Signer,
|
||||
action: &str,
|
||||
message: &str,
|
||||
) -> (Vec<u8>, [u8; 64]) {
|
||||
let create = now();
|
||||
let req = ApprovalRequest {
|
||||
sender: sender.pubkey(),
|
||||
recipient: recipient.pubkey(),
|
||||
action: action.to_string(),
|
||||
payload: Default::default(),
|
||||
message: message.to_string(),
|
||||
created_at: create,
|
||||
expires_at: create + 40,
|
||||
nonce: [
|
||||
0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d,
|
||||
0x0e, 0x0f,
|
||||
],
|
||||
};
|
||||
let tce = encode_approval_request(&req).expect("encode request");
|
||||
let sig = sender.sign(&tce);
|
||||
(tce, sig)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn live_auth_fetch_respond_roundtrip() {
|
||||
let Some(base) = base() else {
|
||||
eprintln!("skipping live_auth_fetch_respond_roundtrip: NIKO_RELAY not set");
|
||||
return;
|
||||
};
|
||||
|
||||
let recipient = Signer::generate().expect("recipient signer");
|
||||
let sender = Signer::generate().expect("sender signer");
|
||||
|
||||
// Authenticate as the recipient with the generic "read" scope.
|
||||
let relay = Relay::auth(&base, &recipient, "*").expect("auth handshake");
|
||||
assert!(!relay.audience().is_empty());
|
||||
|
||||
// Sender posts an ApprovalRequest addressed to the recipient.
|
||||
let (req_tce, req_sig) = mk_request(&sender, &recipient, "login", "Approve 2FA login");
|
||||
let sender_agent = Relay::auth(&base, &sender, "*").expect("sender auth");
|
||||
let req_id = sender_agent.store(&req_tce, &req_sig).expect("store request");
|
||||
assert!(!req_id.is_empty());
|
||||
|
||||
// Recipient fetches, decodes and verifies the incoming request.
|
||||
let inbox = relay.fetch_requests(&recipient).expect("fetch requests");
|
||||
assert_eq!(inbox.len(), 1, "expected exactly one new request");
|
||||
let inc = &inbox[0];
|
||||
assert_eq!(inc.request.action, "login");
|
||||
assert_eq!(inc.request.message, "Approve 2FA login");
|
||||
|
||||
// Respond Allow; the relay must accept it.
|
||||
let resp_id = relay
|
||||
.respond(&recipient, &inc.request, &req_tce, Decision::Allow)
|
||||
.expect("respond allow");
|
||||
assert!(!resp_id.is_empty());
|
||||
|
||||
// Responding again to the same request must be refused by the server.
|
||||
let again = relay.respond(&recipient, &inc.request, &req_tce, Decision::Deny);
|
||||
assert!(again.is_err(), "second response to a request must be rejected");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn live_rejects_wrong_recipient() {
|
||||
let Some(base) = base() else {
|
||||
eprintln!("skipping live_rejects_wrong_recipient: NIKO_RELAY not set");
|
||||
return;
|
||||
};
|
||||
|
||||
let me = Signer::generate().expect("me signer");
|
||||
let someone_else = Signer::generate().expect("victim signer");
|
||||
let sender = Signer::generate().expect("sender signer");
|
||||
|
||||
let relay = Relay::auth(&base, &me, "*").expect("auth");
|
||||
|
||||
// Request addressed to someone_else: polling as `me` must yield nothing.
|
||||
let (req_tce, req_sig) = mk_request(&sender, &someone_else, "x", "not for me");
|
||||
let sender_agent = Relay::auth(&base, &sender, "*").expect("sender auth");
|
||||
sender_agent.store(&req_tce, &req_sig).expect("store");
|
||||
|
||||
let inbox = relay.fetch_requests(&me).expect("fetch");
|
||||
assert!(
|
||||
inbox.iter().all(|r| r.request.recipient != me.pubkey()),
|
||||
"no request may target my key that was addressed elsewhere"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn live_ignores_poisoned_envelope() {
|
||||
let Some(base) = base() else {
|
||||
eprintln!("skipping live_ignores_poisoned_envelope: NIKO_RELAY not set");
|
||||
return;
|
||||
};
|
||||
|
||||
let me = Signer::generate().expect("me signer");
|
||||
let sender = Signer::generate().expect("sender signer");
|
||||
let relay = Relay::auth(&base, &me, "*").expect("auth");
|
||||
|
||||
// A request addressed to me but signed by a *different* key. The relay
|
||||
// stores any well-formed envelope without checking signatures (INV-5), so
|
||||
// this lands in the feed; the client must skip it because the signature
|
||||
// does not verify against the request's sender field.
|
||||
let timestamp = now() - 1000;
|
||||
let req = ApprovalRequest {
|
||||
sender: sender.pubkey(),
|
||||
recipient: me.pubkey(),
|
||||
action: "phish".to_string(),
|
||||
payload: Default::default(),
|
||||
message: "".to_string(),
|
||||
created_at: timestamp,
|
||||
expires_at: timestamp + 30,
|
||||
nonce: [7u8; NONCE_SIZE],
|
||||
};
|
||||
let tce = encode_approval_request(&req).expect("encode");
|
||||
|
||||
// Wrong signer: forge a signature with an unrelated identity.
|
||||
let forger = Signer::generate().expect("forger signer");
|
||||
let bad_sig = forger.sign(&tce);
|
||||
|
||||
let sender_agent = Relay::auth(&base, &sender, "*").expect("sender auth");
|
||||
let _ = sender_agent.store(&tce, &bad_sig).expect("store poisoned");
|
||||
|
||||
let inbox = relay.fetch_requests(&me).expect("fetch");
|
||||
assert!(
|
||||
inbox.iter().all(|r| r.request.sender != sender.pubkey() || r.request.action != "phish"),
|
||||
"envelope with a forged signature must be dropped from the inbox"
|
||||
);
|
||||
}
|
||||
/// Regression: after a restart the in-memory inbox is empty, so an
|
||||
/// already-answered request used to reappear as "pending" and re-answering
|
||||
/// failed with 422. The client must reconcile each new request against the
|
||||
/// relay's responses feed and restore the recorded outcome.
|
||||
#[test]
|
||||
fn live_restart_reconciles_answered_requests() {
|
||||
let Some(base) = base() else {
|
||||
eprintln!("skipping live_restart_reconciles_answered_requests: NIKO_RELAY not set");
|
||||
return;
|
||||
};
|
||||
|
||||
let recipient = Signer::generate().expect("recipient signer");
|
||||
let sender = Signer::generate().expect("sender signer");
|
||||
|
||||
fn mk(
|
||||
sender: &Signer,
|
||||
recipient: &Signer,
|
||||
message: &str,
|
||||
nonce_byte: u8,
|
||||
) -> (Vec<u8>, [u8; 64]) {
|
||||
let create = now();
|
||||
let req = ApprovalRequest {
|
||||
sender: sender.pubkey(),
|
||||
recipient: recipient.pubkey(),
|
||||
action: "login".to_string(),
|
||||
payload: Default::default(),
|
||||
message: message.to_string(),
|
||||
created_at: create,
|
||||
expires_at: create + 40,
|
||||
nonce: [nonce_byte; NONCE_SIZE],
|
||||
};
|
||||
let tce = encode_approval_request(&req).expect("encode");
|
||||
let sig = sender.sign(&tce);
|
||||
(tce, sig)
|
||||
}
|
||||
|
||||
let relay = Relay::auth(&base, &recipient, "*").expect("recipient auth");
|
||||
let sender_agent = Relay::auth(&base, &sender, "*").expect("sender auth");
|
||||
|
||||
// Two distinct requests; answer one Allow, the other Deny.
|
||||
let (tce1, sig1) = mk(&sender, &recipient, "restart-allow", 0x01);
|
||||
sender_agent.store(&tce1, &sig1).expect("store 1");
|
||||
let (tce2, sig2) = mk(&sender, &recipient, "restart-deny", 0x02);
|
||||
sender_agent.store(&tce2, &sig2).expect("store 2");
|
||||
|
||||
let list = relay.fetch_requests(&recipient).expect("fetch");
|
||||
for r in &list {
|
||||
let d = if r.request.message == "restart-allow" {
|
||||
Decision::Allow
|
||||
} else {
|
||||
Decision::Deny
|
||||
};
|
||||
relay
|
||||
.respond(&recipient, &r.request, &r.tce, d)
|
||||
.expect("respond as recipient");
|
||||
}
|
||||
|
||||
// Simulate a restart: a brand-new inbox that has never seen anything.
|
||||
let mut inbox = Inbox::new();
|
||||
let list2 = relay.fetch_requests(&recipient).expect("refetch after restart");
|
||||
let new_items = inbox.observe(now(), &list2);
|
||||
assert_eq!(new_items.len(), 2, "both requests are new to the fresh inbox");
|
||||
|
||||
// Reconcile exactly like the GUI does (AnswerChecked).
|
||||
for r in new_items {
|
||||
let rid = hex::encode(r.request_id);
|
||||
let resps = relay.fetch_responses(&rid).expect("responses feed");
|
||||
let mine = resps
|
||||
.into_iter()
|
||||
.find(|x| x.response.responder == recipient.pubkey())
|
||||
.expect("request was answered before the restart");
|
||||
inbox.set_outcome(&r.request_id, Outcome::from(mine.response.decision));
|
||||
}
|
||||
|
||||
let h = inbox.history();
|
||||
let allow = h.iter().find(|x| x.message == "restart-allow").unwrap();
|
||||
let deny = h.iter().find(|x| x.message == "restart-deny").unwrap();
|
||||
assert_eq!(allow.outcome, Outcome::Approved, "answered-allow must not be pending");
|
||||
assert_eq!(deny.outcome, Outcome::Denied, "answered-deny must not be pending");
|
||||
assert_eq!(inbox.pending_count(), 0, "nothing may be left pending after reconciliation");
|
||||
}
|
||||
260
tests/tce_vectors.rs
Normal file
260
tests/tce_vectors.rs
Normal file
|
|
@ -0,0 +1,260 @@
|
|||
//! Byte-exact conformance against the frozen TCE vectors produced by the
|
||||
//! reference implementation. The vectors file lives in the relay repo at
|
||||
//! ~/niko_trust/testdata/vectors/tce_vectors.json and is the normative ground
|
||||
//! truth. If the file is absent the tests are skipped (keeps this crate
|
||||
//! self-contained).
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use niko_trust_gui::address::Address;
|
||||
use niko_trust_gui::protocol::{self, Decision, MAX_CLOCK_SKEW};
|
||||
use niko_trust_gui::signer::{self, Signer};
|
||||
use niko_trust_gui::tce::number::canonical_number;
|
||||
use niko_trust_gui::tce::value::Value;
|
||||
use serde_json::Value as Json;
|
||||
|
||||
fn vectors_path() -> PathBuf {
|
||||
if let Ok(p) = std::env::var("NIKO_VECTORS") {
|
||||
return PathBuf::from(p);
|
||||
}
|
||||
let d = std::env::var("CARGO_MANIFEST_DIR").unwrap_or_else(|_| ".".into());
|
||||
Path::new(&d).join("../niko_trust/testdata/vectors/tce_vectors.json")
|
||||
}
|
||||
|
||||
fn load_vectors() -> Option<Json> {
|
||||
let p = vectors_path();
|
||||
if !p.exists() {
|
||||
eprintln!("skipping: vectors file not found at {}", p.display());
|
||||
return None;
|
||||
}
|
||||
let raw = std::fs::read_to_string(&p).expect("read vectors");
|
||||
serde_json::from_str(&raw).map(Some).expect("parse vectors json")
|
||||
}
|
||||
|
||||
fn pubkey_of(addr: &str) -> [u8; 32] {
|
||||
let a = Address::parse(addr).expect("valid address");
|
||||
*a.pubkey()
|
||||
}
|
||||
|
||||
fn seed_for_pubkey(parties: &Json, pubkey_hex: &str) -> [u8; 32] {
|
||||
for (_name, p) in parties.as_object().unwrap() {
|
||||
if p["pubkey_hex"].as_str() == Some(pubkey_hex) {
|
||||
let s = hex::decode(p["seed_hex"].as_str().unwrap()).unwrap();
|
||||
return s.try_into().unwrap();
|
||||
}
|
||||
}
|
||||
panic!("party not found for pubkey {pubkey_hex}")
|
||||
}
|
||||
|
||||
fn signer_for(parties: &Json, pubkey_hex: &str) -> Signer {
|
||||
Signer::from_seed(seed_for_pubkey(parties, pubkey_hex)).expect("signer")
|
||||
}
|
||||
|
||||
fn hex_bytes(s: &str) -> Vec<u8> {
|
||||
hex::decode(s).expect("hex")
|
||||
}
|
||||
|
||||
fn value_from_json(v: &Json) -> Value {
|
||||
match v {
|
||||
Json::Null => Value::Null,
|
||||
Json::Bool(b) => Value::Bool(*b),
|
||||
Json::String(s) => Value::Str(s.clone()),
|
||||
Json::Number(n) => Value::Number(n.to_string()),
|
||||
other => panic!("unsupported value in vector json: {other}"),
|
||||
}
|
||||
}
|
||||
|
||||
fn map_from_json(v: &Json) -> BTreeMap<String, Value> {
|
||||
let mut m = BTreeMap::new();
|
||||
for (k, val) in v.as_object().unwrap() {
|
||||
m.insert(k.clone(), value_from_json(val));
|
||||
}
|
||||
m
|
||||
}
|
||||
|
||||
fn nonce_from_json(v: &Json) -> [u8; 16] {
|
||||
hex_bytes(v.as_str().unwrap()).try_into().unwrap()
|
||||
}
|
||||
|
||||
fn hash_from_json(v: &Json) -> [u8; 32] {
|
||||
hex_bytes(v.as_str().unwrap()).try_into().unwrap()
|
||||
}
|
||||
|
||||
fn check_vector(parties: &Json, v: &Json) {
|
||||
let tce_expected = hex_bytes(v["tce_hex"].as_str().unwrap());
|
||||
let object_id = v["object_id_hex"].as_str().unwrap();
|
||||
let signer_key = v["signer_pubkey_hex"].as_str().unwrap();
|
||||
let _ = v["signature_hex"];
|
||||
let j = &v["json"];
|
||||
let typ = j["type"].as_str().unwrap();
|
||||
let signer = signer_for(parties, signer_key);
|
||||
|
||||
// Build the typed object from the json view.
|
||||
let encoded: Vec<u8> = match typ {
|
||||
"identity" => protocol::encode_identity(&protocol::Identity {
|
||||
pubkey: signer.pubkey(),
|
||||
alias: j["alias"].as_str().unwrap().to_string(),
|
||||
created_at: j["created_at"].as_u64().unwrap(),
|
||||
})
|
||||
.unwrap(),
|
||||
"claim" => protocol::encode_claim(&protocol::Claim {
|
||||
issuer: signer.pubkey(),
|
||||
subject: pubkey_of(j["subject"].as_str().unwrap()),
|
||||
claims: map_from_json(&j["claims"]),
|
||||
created_at: j["created_at"].as_u64().unwrap(),
|
||||
expires_at: j["expires_at"].as_u64().unwrap(),
|
||||
serial: j["serial"].as_u64().unwrap(),
|
||||
nonce: nonce_from_json(&j["nonce"]),
|
||||
})
|
||||
.unwrap(),
|
||||
"revocation" => protocol::encode_revocation(&protocol::Revocation {
|
||||
issuer: signer.pubkey(),
|
||||
claim_id: hash_from_json(&j["claim_id"]),
|
||||
reason: j["reason"].as_str().unwrap().to_string(),
|
||||
created_at: j["created_at"].as_u64().unwrap(),
|
||||
nonce: nonce_from_json(&j["nonce"]),
|
||||
})
|
||||
.unwrap(),
|
||||
"approval_request" => protocol::encode_approval_request(&protocol::ApprovalRequest {
|
||||
sender: signer.pubkey(),
|
||||
recipient: pubkey_of(j["recipient"].as_str().unwrap()),
|
||||
action: j["action"].as_str().unwrap().to_string(),
|
||||
payload: map_from_json(&j["payload"]),
|
||||
message: j["message"].as_str().unwrap().to_string(),
|
||||
created_at: j["created_at"].as_u64().unwrap(),
|
||||
expires_at: j["expires_at"].as_u64().unwrap(),
|
||||
nonce: nonce_from_json(&j["nonce"]),
|
||||
})
|
||||
.unwrap(),
|
||||
"approval_response" => {
|
||||
let decision = match j["decision"].as_str().unwrap() {
|
||||
"allow" => Decision::Allow,
|
||||
"deny" => Decision::Deny,
|
||||
other => panic!("bad decision {other}"),
|
||||
};
|
||||
protocol::encode_approval_response(&protocol::ApprovalResponse {
|
||||
request_hash: hash_from_json(&j["request_hash"]),
|
||||
responder: signer.pubkey(),
|
||||
decision,
|
||||
created_at: j["created_at"].as_u64().unwrap(),
|
||||
nonce: nonce_from_json(&j["nonce"]),
|
||||
})
|
||||
.unwrap()
|
||||
}
|
||||
"auth_assertion" => protocol::encode_auth_assertion(&protocol::AuthAssertion {
|
||||
pubkey: signer.pubkey(),
|
||||
challenge: hash_from_json(&j["challenge"]),
|
||||
scope: j["scope"].as_str().unwrap().to_string(),
|
||||
audience: j["audience"].as_str().unwrap().to_string(),
|
||||
created_at: j["created_at"].as_u64().unwrap(),
|
||||
})
|
||||
.unwrap(),
|
||||
other => panic!("unknown vector type {other}"),
|
||||
};
|
||||
|
||||
assert_eq!(encoded, tce_expected, "vector {}: encoded bytes differ", v["name"]);
|
||||
assert_eq!(
|
||||
hex::encode(niko_trust_gui::crypto::sha256(&encoded)),
|
||||
object_id,
|
||||
"vector {}: object id differs",
|
||||
v["name"]
|
||||
);
|
||||
|
||||
// Deterministic Ed25519 must reproduce the reference signature.
|
||||
let sig = signer.sign(&encoded);
|
||||
assert_eq!(hex::encode(sig), v["signature_hex"], "vector {}: signature differs", v["name"]);
|
||||
|
||||
// Rule checks on the decoded form.
|
||||
let _ = MAX_CLOCK_SKEW;
|
||||
|
||||
// decode -> re-encode round trip must be byte-identical.
|
||||
let reencoded: Vec<u8> = match typ {
|
||||
"identity" => {
|
||||
let d = protocol::decode_identity(&tce_expected).unwrap();
|
||||
protocol::encode_identity(&d).unwrap()
|
||||
}
|
||||
"claim" => {
|
||||
let d = protocol::decode_claim(&tce_expected).unwrap();
|
||||
protocol::encode_claim(&d).unwrap()
|
||||
}
|
||||
"revocation" => {
|
||||
let d = protocol::decode_revocation(&tce_expected).unwrap();
|
||||
protocol::encode_revocation(&d).unwrap()
|
||||
}
|
||||
"approval_request" => {
|
||||
let d = protocol::decode_approval_request(&tce_expected).unwrap();
|
||||
protocol::encode_approval_request(&d).unwrap()
|
||||
}
|
||||
"approval_response" => {
|
||||
let d = protocol::decode_approval_response(&tce_expected).unwrap();
|
||||
protocol::encode_approval_response(&d).unwrap()
|
||||
}
|
||||
"auth_assertion" => {
|
||||
let d = protocol::decode_auth_assertion(&tce_expected).unwrap();
|
||||
protocol::encode_auth_assertion(&d).unwrap()
|
||||
}
|
||||
_ => unreachable!(),
|
||||
};
|
||||
assert_eq!(reencoded, tce_expected, "vector {}: decode/re-encode differs", v["name"]);
|
||||
|
||||
// Signature verification over the exact bytes with the embedded key.
|
||||
let verify_pub = match typ {
|
||||
"claim" => pubkey_of(j["issuer"].as_str().unwrap()),
|
||||
"approval_response" => signer.pubkey(),
|
||||
"revocation" => signer.pubkey(),
|
||||
"approval_request" => signer.pubkey(),
|
||||
"auth_assertion" => signer.pubkey(),
|
||||
"identity" => signer.pubkey(),
|
||||
_ => unreachable!(),
|
||||
};
|
||||
let sig_vec = hex_bytes(v["signature_hex"].as_str().unwrap());
|
||||
let sig_arr: [u8; 64] = sig_vec.try_into().unwrap();
|
||||
signer::verify(&verify_pub, &tce_expected, &sig_arr).expect("signature must verify");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn frozen_tce_vectors_byte_exact() {
|
||||
let Some(doc) = load_vectors() else { return };
|
||||
let parties = &doc["parties"];
|
||||
for v in doc["vectors"].as_array().unwrap() {
|
||||
check_vector(parties, v);
|
||||
}
|
||||
assert!(doc["vectors"].as_array().unwrap().len() >= 9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn number_canonicalization_vectors() {
|
||||
let Some(doc) = load_vectors() else { return };
|
||||
let nc = &doc["number_canonicalization"];
|
||||
for (input, want) in nc["accept"].as_object().unwrap() {
|
||||
assert_eq!(
|
||||
canonical_number(input).ok(),
|
||||
Some(want.as_str().unwrap().to_string()),
|
||||
"accept {input}"
|
||||
);
|
||||
}
|
||||
for (input, _reason) in nc["reject"].as_object().unwrap() {
|
||||
assert!(canonical_number(input).is_err(), "reject {input}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn strict_decoder_rejects() {
|
||||
let Some(doc) = load_vectors() else { return };
|
||||
// Every reject vector is hex bytes that a strict decoder must refuse as a
|
||||
// Claim (the reference uses a claim context for these). Failing to decode
|
||||
// is the assertion.
|
||||
for r in doc["rejects"].as_array().unwrap() {
|
||||
let name = r["name"].as_str().unwrap();
|
||||
// Some reject rows are prose descriptions without bytes; only rows
|
||||
// carrying tce_hex are byte-checkable here.
|
||||
let Some(b) = r["tce_hex"].as_str().map(hex_bytes) else {
|
||||
continue;
|
||||
};
|
||||
assert!(
|
||||
niko_trust_gui::protocol::decode_claim(&b).is_err(),
|
||||
"reject vector {name} must fail to decode"
|
||||
);
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue