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:
@@ -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;
|
||||
|
||||
|
||||
@@ -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() }));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user