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:
Niko Marmeladkov 2026-08-22 23:20:41 +03:00
commit 6278321873
Signed by: Niko
GPG key ID: E3B955F9442D44E3
27 changed files with 11213 additions and 0 deletions

13
.gitignore vendored Normal file
View 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

File diff suppressed because it is too large Load diff

56
Cargo.toml Normal file
View 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
View 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_at120s, expires_at+120s].
5. Показать: message + sender-address + действие. NOT как endorsement от релея.
6. Approve → `ApprovalResponse{request_hash, responder=mypub, decision=1, created_at=now, nonce=rand16}`
→ подписать → `POST /v1/objects`. Deny → decision=0.
7. Один ответ на запрос — сервер отклонит второй (422).
## Тестовые векторы (эталон)
- Партии: NikoCraft seed=`01`×32, Niko seed=`02`×32 (указы в vectors: pubkey, address).
- Проверки: encoder даёт тот же hex; object_id совпадает; dalek verify подписи ok;
decoder→re-encode тождество; адреса совпадают; таблица canonical numbers;
выборочные rejects на decoder.
## Статус
- [x] Изучен сервер-репо, протокол, API, векторы (source review + read-only smoke GET:
healthz/readyz/config ok, claims без токена → честный 401). Видимых багов сервера нет.
- [x] OPENCODE.md создан.
- [x] Скаффолд Cargo (feature `gui` = iced; `cargo test --no-default-features` = быстрое ядро без iced).
- [x] tce (encoder/decoder/number) + address (свой bech32m) + signer (ed25519-dalek 3, is_weak+verify_strict)
+ protocol (6 объектов). **Все frozen vectors byte-exact: encode→hex, object_id, подпись (dalek
воспроизводит детерменированные подписи), decode→re-encode, reject-набор, числа, адреса.** clippy чист.
- [x] keyring (argon2id + XChaCha20-Poly1305, файл 0600). **Готово**: `src/keyring.rs`,
`FileFormat{v,kdf,m,t,p,salt,nonce,cipher}` (base64), `aead` добавлен в deps (0.6),
`XNonce::from`, 5 unit-тестов (roundtrip, wrong-password, kdf-params), clippy чист.
- [x] relay + live-тест. **Готово**: `src/relay.rs` + `tests/live_relay.rs` (env `NIKO_RELAY`).
Auth-рукопожатие (config→audience, challenge, AuthAssertion{scope:"*"}, assert→Bearer),
`fetch_requests` (строгая декодировка + validate_pubkey + verify + recipient must = me,
пропускает ядовитые конверты), `respond` (проверка окна запроса ±120с, request_hash=sha256(tce),
POST objects), `store`. **3 live-теста против `192.168.31.155:5743` проходят:**
roundtrip (ответ принят, 2-й ответ на тот же запрос → 422), wrong-recipient отфильтрован,
forged-подпись отброшена. ВАЖНО: у ureq 3.4 API другой (http::Response<Body> +
body_mut().read_json()/read_to_string(), `http_status_as_error(false)` в конфиге агента).
- [x] inbox/verify. **Готово**: `src/inbox.rs``Inbox` (в памяти): dedup по request_id
(каждый запрос всплывает один раз), история `HistoryItem{request_id_hex, sender(Address),
action, message, created_at, expires_at, outcome}`, `in_window(created,expires,now)` с
±120s skew (§13.1), outcome Pending/Approved/Denied/TimedOut/Stale. `observe()` возвращает
только actionable (`Pending`); истёкшие/из будущего записываются в историю как TimedOut/Stale.
`Address` получил `PartialEq, Eq`. 3 unit-теста (dedup, window+outcomes, outcome_changes).
- [x] notify + autostart. **Готово**: `src/notify.rs``ApprovalNotification{sender,action,message}`,
`show()` (notify-rust, кнопки Approve/Deny, ждёт через отдельный поток), `PromptHandle::wait_timeout`,
`PromptOutcome{Approved,Denied,Ignored}`, `to_decision()`, `interactive_supported()` = `dbus_stack().is_some()`.
`src/autostart.rs` — обёртка `auto-launch` (XDG autostart на Linux, registry на Windows), `APP_NAME="niko-trust"`,
`enable/disable/is_enabled`. Smoke-тест `examples/smoke.rs` отработал на этой машине: dbus есть,
уведомление показывается (кнопки кликнуть некому — демона нет, outcome=Ignored после таймаута), autostart is_enabled=false.
Модули `notify`/`autostart` вне feature-gate `gui` (не зависят от iced).
- [x] UI iced (последний блок). **Готово**: `src/main.rs` (модуль `gui`, feature `gui`; вне gui — стаб
`main` с ошибкой). Сборка: iced 0.14 + фича `tokio` (default-бэкенд `thread-pool` имеет пустой
`time`, без tokio/smol нет `iced::time::every`). Экраны: lock (первый запуск = создание
identity, иначе разблокировка; поле relay URL, default `https://trust.n1ko.dev`) и main
(заголовок с адресом/релеем, статус-строка, карточка запроса с Approve/Deny, история).
Poll-loop: `Subscription` `time::every(5s)``Relay::fetch_requests``Inbox::observe`
очередь (`VecDeque`) → текущий `Prompt`. Нативные нотификации: `notify::show` в отдельном
потоке, outcome в UI через `futures::channel::mpsc` + глобальный слот `PROMPT_RX` +
`Subscription::run_with(request_id, |_| iced::stream::channel(...))` (инфраструктура
`iced::stream`/`futures` реэкспортируется iced'ом). Ответ: `Relay::respond` в `Task::perform`,
при истечении окна (проверка `in_window`) помечается TimedOut. **Изменено в ядре**: в
`IncomingRequest` добавлено поле `tce: Vec<u8>` (точные байты запроса нужны для request_hash
при ответе; re-encode не byte-exact). Проверено: `cargo build`+`clippy --all-targets` чистые
с gui, и `--no-default-features`), 10 lib + 3 tce + 3 live тестов зелёные, **live-тесты
прогнаны против production `NIKO_RELAY=https://trust.n1ko.dev` — проходят**, GUI запускается
(без падений, первый запуск = экран создания).
## Команды
- `cargo build` / `cargo test` / `cargo clippy` в `/home/niko/niko_trust_gui`.
- Live-интеграционные тесты: env `NIKO_RELAY=https://trust.n1ko.dev` (или `http://192.168.31.155:5743`).
- Windows: target `x86_64-pc-windows-gnu` установлен; кросс-сборка отдельно.

103
examples/await_2fa.rs Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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

File diff suppressed because it is too large Load diff

119
src/notify.rs Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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"
);
}
}