feat(core): save devices through mutual consent and grants

Establish PendingOutgoing/PendingIncoming relationships over a token-bound
pairing protocol, exchange directional grants with challenge-response proofs
and a final ack before Saved, and merge simultaneous initiations without a
second prompt.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-08-11 03:03:43 +02:00
parent 04a31e8a24
commit 193fe7c757
13 changed files with 1933 additions and 8 deletions

View File

@@ -0,0 +1,134 @@
//! Relationship-grant possession proofs (design §7).
use crate::{
error::VnidropError,
grant::{Challenge, GrantId, GrantProof, GrantSecret},
secure_secret::SecretMaterial,
};
const RELATIONSHIP_GRANT_CONTEXT: &[u8] = b"vnidrop-relationship-grant-v1";
pub(super) fn encode_relationship_grant_secret(
secret: &GrantSecret,
) -> Result<SecretMaterial, VnidropError> {
// Custody stores only the 32-byte secret; issuer/holder/generation/protocol
// bindings live in the relationship row and are enforced at prove/verify time.
SecretMaterial::new(secret.as_bytes().to_vec())
}
pub(super) fn secret_from_material(material: &SecretMaterial) -> Result<GrantSecret, VnidropError> {
let bytes: [u8; 32] =
material
.to_vec()
.try_into()
.map_err(|_| VnidropError::SecureStorageCorrupted {
reason: "relationship grant secret has invalid length".to_string(),
})?;
Ok(GrantSecret::from_bytes(bytes))
}
pub(super) fn prove_relationship_grant(
grant_id: GrantId,
secret: &GrantSecret,
challenge: &Challenge,
issuer: &str,
holder: &str,
generation: u64,
protocol_version: u16,
) -> GrantProof {
let mac = relationship_mac(
secret,
challenge,
issuer,
holder,
generation,
protocol_version,
);
GrantProof::from_parts(grant_id, mac)
}
pub(super) fn verify_relationship_grant(
secret: &GrantSecret,
proof: &GrantProof,
challenge: &Challenge,
issuer: &str,
holder: &str,
generation: u64,
protocol_version: u16,
) -> Result<(), &'static str> {
let expected = relationship_mac(
secret,
challenge,
issuer,
holder,
generation,
protocol_version,
);
if blake3::Hash::from_bytes(expected) != blake3::Hash::from_bytes(*proof.mac()) {
return Err("bad relationship grant proof");
}
Ok(())
}
fn relationship_mac(
secret: &GrantSecret,
challenge: &Challenge,
issuer: &str,
holder: &str,
generation: u64,
protocol_version: u16,
) -> [u8; 32] {
let mut hasher = blake3::Hasher::new_keyed(secret.as_bytes());
hasher.update(RELATIONSHIP_GRANT_CONTEXT);
hasher.update(challenge.as_bytes());
hasher.update(&(issuer.len() as u64).to_le_bytes());
hasher.update(issuer.as_bytes());
hasher.update(&(holder.len() as u64).to_le_bytes());
hasher.update(holder.as_bytes());
hasher.update(&generation.to_le_bytes());
hasher.update(&protocol_version.to_le_bytes());
*hasher.finalize().as_bytes()
}
#[cfg(test)]
mod grant_vectors {
use super::*;
use crate::api::experimental_saved_device_capabilities;
use data_encoding::HEXLOWER;
#[test]
fn relationship_grant_proof_vectors_are_stable() {
let secret =
GrantSecret::decode("0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef")
.unwrap();
let grant_id = GrantId::decode("0123456789abcdef0123456789abcdef").unwrap();
let challenge = Challenge::from_bytes([9u8; 32]);
let protocol = experimental_saved_device_capabilities().relationship_protocol_version;
let proof = prove_relationship_grant(
grant_id, &secret, &challenge, "issuer", "holder", 1, protocol,
);
// Binding and replay resistance: wrong holder or challenge must fail.
verify_relationship_grant(&secret, &proof, &challenge, "issuer", "holder", 1, protocol)
.unwrap();
let mac_hex = HEXLOWER.encode(proof.mac());
assert_eq!(
mac_hex,
"e6cc2641183b84fae9e3805761961d69e09d25a1f8ceeaeede952774ddd95d6b"
);
assert!(verify_relationship_grant(
&secret, &proof, &challenge, "issuer", "other", 1, protocol,
)
.is_err());
let other_challenge = Challenge::from_bytes([8u8; 32]);
assert!(verify_relationship_grant(
&secret,
&proof,
&other_challenge,
"issuer",
"holder",
1,
protocol,
)
.is_err());
}
}

File diff suppressed because it is too large Load Diff