Onboarding core: setup payloads, enrollment tickets, scan-to-join (docs/10)

* kv-proto: ticket and join wire types, LoginFinish.ticket, b64_opt.
* kv-crypto: pairing.rs (HPKE AuthPSK seal/open with the QR secret as PSK, SAS code).
* kv-sync: onboarding.rs (kestrove://setup/v1 and kestrove://join/v1 codec with
  strict validation), ApiClient ticket/join calls and login_with_ticket, test
  server support for tickets and rendezvous.
* kv-ffi: parse/encode_setup_payload, setup_payload_for_kit, add_device_payload,
  revoke_enrollment_ticket, login_with_setup, join_start/poll/cancel,
  join_approve/send. The Secret Key is kept locally, HPKE-sealed to enc_pk.
* Tests: codec round trips and rejections, AuthPSK tamper cases, end-to-end
  Add-device QR and scan-to-join between two clients, forged bundles, bound
  tickets; live test extended with scan-to-join.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
2026-10-17 23:49:48 +09:00
co-authored by Claude Opus 5.5
parent f20380d293
commit 266fb90e45
14 changed files with 1409 additions and 17 deletions
+22 -4
View File
@@ -1,13 +1,15 @@
//! Two devices against a real, freshly started kestrove-server. Ignored by default.
//!
//! ```sh
//! # in kestrove-server (empty database, so it prints SETUP_TOKEN=… on boot):
//! KV_DATABASE=sqlite::memory: KV_BIND=127.0.0.1:8080 KV_PUBLIC_URL=http://127.0.0.1:8080 cargo run
//! # in kestrove-server (empty database; without KV_SETUP_CODE it prints SETUP_TOKEN=… on boot instead):
//! KV_DATABASE=sqlite::memory: KV_BIND=127.0.0.1:8080 KV_PUBLIC_URL=http://127.0.0.1:8080 \
//! KV_SETUP_CODE=live-test-setup-code cargo run
//! # here:
//! KESTROVE_SERVER_URL=http://127.0.0.1:8080 KESTROVE_SETUP_TOKEN=<token> \
//! KESTROVE_SERVER_URL=http://127.0.0.1:8080 KESTROVE_SETUP_TOKEN=live-test-setup-code \
//! cargo test -p kv-ffi --test live_server -- --ignored --nocapture
//! ```
//! The setup token works once, so restart the server with an empty database for every run.
//! The setup code works once, so restart the server with an empty database for every run. The run takes about
//! four minutes because it waits out the server's rate limit on the login endpoints.
use std::sync::Arc;
@@ -107,6 +109,22 @@ async fn two_devices_against_local_server() {
b.sync_now().await.unwrap();
assert!(matches!(b.get_item(temp), Err(CoreError::NotFound)));
// Scan-to-join (docs/10 §4): a third device that knows only the server URL needs no approval.
tokio::time::sleep(std::time::Duration::from_secs(61)).await; // rate limit again
let c = VaultCore::new_in_memory();
connect(&c, &url, "device C");
let session = c.join_start(url.clone()).await.unwrap();
let confirm = a.join_approve(session.qr).await.unwrap();
assert_eq!(confirm.device_name, "device C");
assert_eq!(c.join_poll().await.unwrap().code.as_deref(), Some(confirm.code.as_str()));
a.join_send().await.unwrap();
tokio::time::sleep(std::time::Duration::from_millis(1100)).await; // the server allows one poll per second
let setup = c.join_poll().await.unwrap().setup.expect("bundle");
assert_eq!(setup.secret_key, sk);
assert_eq!(c.login_with_setup(setup, pw.into()).await.unwrap(), LoginResult::Ok);
c.sync_now().await.unwrap();
assert!(c.list_items().unwrap().iter().any(|i| i.title == "Bank"));
b.logout().await.unwrap();
println!("live sync OK against {url}");
}