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
+1
View File
@@ -9,6 +9,7 @@ pub mod aad;
pub mod aead;
pub mod kdf;
pub mod keys;
pub mod pairing;
pub mod seal;
pub mod secret_key;
+157
View File
@@ -0,0 +1,157 @@
//! Scan-to-join encryption (kestrove-spec/docs/10 §4): HPKE (RFC 9180) in AuthPSK mode with the suite of
//! [`crate::seal`], plus the short authentication string both screens show.
//!
//! * The joining device's ephemeral key and the PSK (`qr_secret`) travel only through the QR code, so the
//! server can neither read nor forge a bundle.
//! * Auth mode binds the bundle to the approving device's ephemeral key from the `hello`, which the code
//! is computed over.
use hpke::{
aead::ChaCha20Poly1305, kdf::HkdfSha256, kem::X25519HkdfSha256, Deserializable, Kem as KemTrait, OpModeR, OpModeS,
PskBundle, Serializable,
};
use kv_proto::{Alg, Envelope, FORMAT_VERSION};
use rand_core::OsRng;
use sha2::{Digest, Sha256};
use uuid::Uuid;
use zeroize::Zeroizing;
use crate::{CryptoError, Result};
type Kem = X25519HkdfSha256;
const INFO: &[u8] = b"kestrove/v1/join";
const SAS_LABEL: &[u8] = b"kestrove/v1/join/sas";
/// Length of `qr_secret`, the HPKE PSK.
pub const PSK_LEN: usize = 32;
/// An ephemeral X25519 key for one join attempt (either side).
pub struct PairingKeypair {
sk: <Kem as KemTrait>::PrivateKey,
pk: <Kem as KemTrait>::PublicKey,
}
impl PairingKeypair {
pub fn generate() -> Self {
let (sk, pk) = Kem::gen_keypair(&mut OsRng);
Self { sk, pk }
}
pub fn public_bytes(&self) -> [u8; 32] {
self.pk.to_bytes().into()
}
}
fn aad(rid: &[u8]) -> Vec<u8> {
let mut aad = INFO.to_vec();
aad.push(0);
aad.extend_from_slice(rid);
aad
}
fn public(bytes: &[u8]) -> Result<<Kem as KemTrait>::PublicKey> {
<Kem as KemTrait>::PublicKey::from_bytes(bytes).map_err(|_| CryptoError::Malformed)
}
/// Approving device: seals `plaintext` to the joining device's key from the QR.
pub fn seal(
sender: &PairingKeypair,
recipient_pub: &[u8],
psk: &[u8],
rid: &[u8],
plaintext: &[u8],
) -> Result<Envelope> {
if psk.len() != PSK_LEN {
return Err(CryptoError::Malformed);
}
let mode = OpModeS::AuthPsk((sender.sk.clone(), sender.pk.clone()), PskBundle { psk, psk_id: rid });
let (encapped, ct) = hpke::single_shot_seal::<ChaCha20Poly1305, HkdfSha256, Kem, _>(
&mode,
&public(recipient_pub)?,
INFO,
plaintext,
&aad(rid),
&mut OsRng,
)
.map_err(|_| CryptoError::Encrypt)?;
Ok(Envelope {
v: FORMAT_VERSION,
alg: Alg::HpkeX25519ChaCha,
kid: Uuid::nil(),
n: vec![],
ct,
enc: encapped.to_bytes().to_vec(),
})
}
/// Joining device: opens a bundle from the sender whose key arrived in the `hello`.
pub fn open(
recipient: &PairingKeypair,
sender_pub: &[u8],
psk: &[u8],
rid: &[u8],
env: &Envelope,
) -> Result<Zeroizing<Vec<u8>>> {
if env.v != FORMAT_VERSION || env.alg != Alg::HpkeX25519ChaCha {
return Err(CryptoError::Unsupported);
}
let encapped = <Kem as KemTrait>::EncappedKey::from_bytes(&env.enc).map_err(|_| CryptoError::Malformed)?;
let mode = OpModeR::AuthPsk(public(sender_pub)?, PskBundle { psk, psk_id: rid });
hpke::single_shot_open::<ChaCha20Poly1305, HkdfSha256, Kem>(
&mode,
&recipient.sk,
&encapped,
INFO,
&env.ct,
&aad(rid),
)
.map(Zeroizing::new)
.map_err(|_| CryptoError::Decrypt)
}
/// The 6-digit code both screens show: `SHA-256(label || 0x00 || rid || D_pub || P_pub)`, first 4 bytes as a
/// big-endian u32, mod 10^6, formatted `"123 456"`.
pub fn sas(rid: &[u8], joining_pub: &[u8], approving_pub: &[u8]) -> String {
let mut h = Sha256::new();
h.update(SAS_LABEL);
h.update([0]);
h.update(rid);
h.update(joining_pub);
h.update(approving_pub);
let d = h.finalize();
let n = u32::from_be_bytes([d[0], d[1], d[2], d[3]]) % 1_000_000;
format!("{:03} {:03}", n / 1000, n % 1000)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn seal_open_and_tamper() {
let (desk, phone) = (PairingKeypair::generate(), PairingKeypair::generate());
let (psk, rid) = ([3u8; 32], b"rendezvous-id-16");
let env = seal(&phone, &desk.public_bytes(), &psk, rid, b"setup payload").unwrap();
assert_eq!(&**open(&desk, &phone.public_bytes(), &psk, rid, &env).unwrap(), b"setup payload");
// Wrong PSK (no QR), wrong sender (server swapped the hello), other rendezvous, other recipient.
assert!(open(&desk, &phone.public_bytes(), &[4u8; 32], rid, &env).is_err());
assert!(open(&desk, &PairingKeypair::generate().public_bytes(), &psk, rid, &env).is_err());
assert!(open(&desk, &phone.public_bytes(), &psk, b"another-rid-0000", &env).is_err());
assert!(open(&PairingKeypair::generate(), &phone.public_bytes(), &psk, rid, &env).is_err());
let mut bad = env.clone();
bad.ct[0] ^= 1;
assert!(open(&desk, &phone.public_bytes(), &psk, rid, &bad).is_err());
assert!(seal(&phone, &desk.public_bytes(), &[1u8; 16], rid, b"x").is_err(), "short psk");
assert!(seal(&phone, &[0u8; 5], &psk, rid, b"x").is_err());
}
#[test]
fn sas_is_stable_and_order_sensitive() {
// kestrove-spec/vectors/onboarding.json
assert_eq!(sas(&[1; 16], &[2; 32], &[3; 32]), "238 148");
assert_ne!(sas(&[1; 16], &[2; 32], &[3; 32]), sas(&[1; 16], &[3; 32], &[2; 32]));
let code = sas(&[1; 16], &[2; 32], &[3; 32]);
assert_eq!(code.len(), 7);
assert!(code.chars().enumerate().all(|(i, c)| if i == 3 { c == ' ' } else { c.is_ascii_digit() }));
}
}