From 4cfee786fcc8d6f62351f8f335a4ea49714fb8ab Mon Sep 17 00:00:00 2001 From: cdricms <36056008+cdricms@users.noreply.github.com> Date: Thu, 6 Aug 2026 16:01:20 +0200 Subject: [PATCH] feat(core): add grant primitives for device history Grants are the capability a device issues so a known peer may reach it. The issuer is the only party that can validate one, which is what makes consent and revocation enforceable without the peer's cooperation. Pure module: proof construction and constant-time verification bound to the challenge and both endpoint ids, idle expiry renewed on use, and secrets redacted in Debug output. --- Cargo.lock | 1 + crates/vnidrop/Cargo.toml | 1 + crates/vnidrop/src/grant.rs | 332 ++++++++++++++++++++++++++++++ crates/vnidrop/src/lib.rs | 1 + crates/vnidrop/src/tests.rs | 2 + crates/vnidrop/src/tests/grant.rs | 241 ++++++++++++++++++++++ 6 files changed, 578 insertions(+) create mode 100644 crates/vnidrop/src/grant.rs create mode 100644 crates/vnidrop/src/tests/grant.rs diff --git a/Cargo.lock b/Cargo.lock index 4a7f58f..c2f9206 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5227,6 +5227,7 @@ dependencies = [ "data-encoding", "futures", "futures-lite", + "getrandom 0.3.4", "iroh", "iroh-blobs", "iroh-relay", diff --git a/crates/vnidrop/Cargo.toml b/crates/vnidrop/Cargo.toml index cfa2f4d..20f25bd 100644 --- a/crates/vnidrop/Cargo.toml +++ b/crates/vnidrop/Cargo.toml @@ -16,6 +16,7 @@ blake3 = "1.8.3" data-encoding = "2.11.0" futures = "0.3" futures-lite = "2.6.1" +getrandom = "0.3.4" iroh = "1.0.3" iroh-blobs = "0.103.0" irpc = "0.17.0" diff --git a/crates/vnidrop/src/grant.rs b/crates/vnidrop/src/grant.rs new file mode 100644 index 0000000..c7b6b19 --- /dev/null +++ b/crates/vnidrop/src/grant.rs @@ -0,0 +1,332 @@ +//! Grants: the capability a device issues so a known peer may reach it. +//! +//! A history entry is not "I remember this endpoint id", it is "this device +//! issued me a capability". The issuer is the only party that can validate a +//! grant, which is what makes both consent and revocation enforceable: refusing +//! to issue leaves the peer with nothing usable, and deleting the issued record +//! ends the relationship without the peer's cooperation. +//! +//! This module is pure: no storage, no network, no clock of its own. Callers +//! supply `now_ms` so expiry and renewal stay testable. + +// Exercised only by unit tests until the contacts repository and the offer +// protocol consume it. Remove this once those land. +#![allow(dead_code)] + +use std::fmt; + +use anyhow::{bail, Context, Result}; +use data_encoding::HEXLOWER; +use serde::{Deserialize, Serialize}; + +/// Domain separator for the possession proof. Changing this invalidates every +/// outstanding grant, so it is versioned rather than edited. +const PROOF_CONTEXT: &[u8] = b"vnidrop-grant-v1"; + +const GRANT_ID_LEN: usize = 16; +const GRANT_SECRET_LEN: usize = 32; +const CHALLENGE_LEN: usize = 32; +const PROOF_LEN: usize = 32; + +/// Opaque public identifier for a grant. Safe to send in the clear. +#[derive(Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub(crate) struct GrantId([u8; GRANT_ID_LEN]); + +impl GrantId { + pub(crate) fn generate() -> Self { + Self(random_bytes()) + } + + pub(crate) fn encode(&self) -> String { + HEXLOWER.encode(&self.0) + } + + pub(crate) fn decode(value: &str) -> Result { + let bytes = HEXLOWER + .decode(value.as_bytes()) + .context("invalid grant id encoding")?; + let bytes: [u8; GRANT_ID_LEN] = bytes + .try_into() + .map_err(|_| anyhow::anyhow!("invalid grant id length"))?; + Ok(Self(bytes)) + } +} + +impl fmt::Debug for GrantId { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "GrantId({})", self.encode()) + } +} + +/// Key material. Never logged, never emitted in an event, never returned across +/// the UniFFI boundary. +#[derive(Clone, PartialEq, Eq)] +pub(crate) struct GrantSecret([u8; GRANT_SECRET_LEN]); + +impl GrantSecret { + pub(crate) fn generate() -> Self { + Self(random_bytes()) + } + + pub(crate) fn encode(&self) -> String { + HEXLOWER.encode(&self.0) + } + + pub(crate) fn decode(value: &str) -> Result { + let bytes = HEXLOWER + .decode(value.as_bytes()) + .context("invalid grant secret encoding")?; + let bytes: [u8; GRANT_SECRET_LEN] = bytes + .try_into() + .map_err(|_| anyhow::anyhow!("invalid grant secret length"))?; + Ok(Self(bytes)) + } +} + +// Redacted on purpose: a secret must not reach a log line through a derived +// Debug on some enclosing struct. +impl fmt::Debug for GrantSecret { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str("GrantSecret(redacted)") + } +} + +/// Random challenge sent by the issuer to bind a proof to one connection. +#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)] +pub(crate) struct Challenge([u8; CHALLENGE_LEN]); + +impl Challenge { + pub(crate) fn generate() -> Self { + Self(random_bytes()) + } + + #[cfg(test)] + pub(crate) fn from_bytes(bytes: [u8; CHALLENGE_LEN]) -> Self { + Self(bytes) + } +} + +impl fmt::Debug for Challenge { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str("Challenge(..)") + } +} + +/// Proof that the sender holds the secret behind `grant_id`. +#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)] +pub(crate) struct GrantProof { + pub(crate) grant_id: GrantId, + mac: [u8; PROOF_LEN], +} + +impl fmt::Debug for GrantProof { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("GrantProof") + .field("grant_id", &self.grant_id) + .finish_non_exhaustive() + } +} + +/// Why a presented proof was not accepted. +/// +/// `Revoked` is reported to the peer so its client can drop the dead entry. +/// `Unknown` is deliberately also used for blocked endpoints, so blocking +/// cannot be detected by probing. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum GrantRejection { + Unknown, + Revoked, + Expired, + WrongEndpoint, + BadProof, +} + +impl GrantRejection { + pub(crate) fn as_str(self) -> &'static str { + match self { + Self::Unknown => "unknown", + Self::Revoked => "revoked", + Self::Expired => "expired", + Self::WrongEndpoint => "wrong-endpoint", + Self::BadProof => "bad-proof", + } + } +} + +/// A grant as held by the party that issued it. This is the authoritative +/// record: `grants_held` on the peer is only a copy for display. +#[derive(Debug, Clone)] +pub(crate) struct IssuedGrant { + pub(crate) grant_id: GrantId, + pub(crate) secret: GrantSecret, + /// The grant is usable only by this endpoint, so it cannot be lent onward. + pub(crate) issued_to_endpoint_id: String, + pub(crate) created_at: i64, + /// Idle expiry, pushed forward on every accepted proof. `None` never expires. + pub(crate) expires_at: Option, + pub(crate) revoked_at: Option, +} + +impl IssuedGrant { + pub(crate) fn mint( + issued_to_endpoint_id: String, + now_ms: i64, + lifetime: GrantLifetime, + ) -> Self { + Self { + grant_id: GrantId::generate(), + secret: GrantSecret::generate(), + issued_to_endpoint_id, + created_at: now_ms, + expires_at: lifetime.deadline_from(now_ms), + revoked_at: None, + } + } + + /// Validate a proof presented by `remote_endpoint_id` over this connection's + /// challenge. Returns the renewed expiry the caller must persist. + /// + /// Checks run in a fixed order so a caller cannot learn more from an early + /// return than from a late one: revocation and expiry are properties of the + /// issuer's own record, and the endpoint binding is checked before the MAC + /// so a stolen grant cannot be probed for validity from another device. + pub(crate) fn accept( + &self, + proof: &GrantProof, + challenge: &Challenge, + issuer_endpoint_id: &str, + remote_endpoint_id: &str, + now_ms: i64, + lifetime: GrantLifetime, + ) -> Result, GrantRejection> { + if proof.grant_id != self.grant_id { + return Err(GrantRejection::Unknown); + } + if self.revoked_at.is_some() { + return Err(GrantRejection::Revoked); + } + if self.is_expired(now_ms) { + return Err(GrantRejection::Expired); + } + if remote_endpoint_id != self.issued_to_endpoint_id { + return Err(GrantRejection::WrongEndpoint); + } + + let expected = compute_proof( + &self.secret, + challenge, + issuer_endpoint_id, + remote_endpoint_id, + ); + // Constant-time: blake3::Hash's PartialEq is constant-time by design. + if !constant_time_eq(&expected, &proof.mac) { + return Err(GrantRejection::BadProof); + } + + Ok(lifetime.deadline_from(now_ms)) + } + + pub(crate) fn is_expired(&self, now_ms: i64) -> bool { + self.expires_at + .is_some_and(|expires_at| expires_at < now_ms) + } + + pub(crate) fn is_active(&self, now_ms: i64) -> bool { + self.revoked_at.is_none() && !self.is_expired(now_ms) + } +} + +/// How long a grant survives without use. Grants expire on idleness rather than +/// age, so a relationship in regular use never lapses while a forgotten one +/// cleans itself up. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum GrantLifetime { + Days(u32), + Never, +} + +impl GrantLifetime { + pub(crate) const DEFAULT_DAYS: u32 = 90; + + pub(crate) fn deadline_from(self, now_ms: i64) -> Option { + match self { + Self::Never => None, + Self::Days(days) => Some(now_ms + i64::from(days) * 24 * 60 * 60 * 1_000), + } + } +} + +impl Default for GrantLifetime { + fn default() -> Self { + Self::Days(Self::DEFAULT_DAYS) + } +} + +/// Build the proof for a grant this device holds. +pub(crate) fn prove( + grant_id: GrantId, + secret: &GrantSecret, + challenge: &Challenge, + issuer_endpoint_id: &str, + holder_endpoint_id: &str, +) -> GrantProof { + GrantProof { + grant_id, + mac: compute_proof(secret, challenge, issuer_endpoint_id, holder_endpoint_id), + } +} + +/// Keyed MAC over the challenge and both endpoint identities. +/// +/// Binding the challenge stops a captured proof being replayed; binding both +/// endpoint ids stops it being replayed against a different peer. Lengths are +/// prefixed so two different id pairs cannot produce the same input. +fn compute_proof( + secret: &GrantSecret, + challenge: &Challenge, + issuer_endpoint_id: &str, + holder_endpoint_id: &str, +) -> [u8; PROOF_LEN] { + let mut input = Vec::with_capacity( + PROOF_CONTEXT.len() + + CHALLENGE_LEN + + issuer_endpoint_id.len() + + holder_endpoint_id.len() + + 16, + ); + input.extend_from_slice(PROOF_CONTEXT); + input.extend_from_slice(&challenge.0); + push_length_prefixed(&mut input, issuer_endpoint_id.as_bytes()); + push_length_prefixed(&mut input, holder_endpoint_id.as_bytes()); + *blake3::keyed_hash(&secret.0, &input).as_bytes() +} + +fn push_length_prefixed(buffer: &mut Vec, bytes: &[u8]) { + buffer.extend_from_slice(&(bytes.len() as u64).to_le_bytes()); + buffer.extend_from_slice(bytes); +} + +fn constant_time_eq(left: &[u8; PROOF_LEN], right: &[u8; PROOF_LEN]) -> bool { + // blake3::Hash compares in constant time; reuse it rather than hand-rolling. + blake3::Hash::from_bytes(*left) == blake3::Hash::from_bytes(*right) +} + +/// Cryptographically secure random bytes. +/// +/// Panics if the OS entropy source fails. That is unrecoverable and must never +/// degrade into a weak grant, so it is not surfaced as a fallible API. +fn random_bytes() -> [u8; N] { + let mut bytes = [0u8; N]; + getrandom::fill(&mut bytes).expect("OS entropy source unavailable"); + bytes +} + +/// Parse a stored grant secret, rejecting anything malformed rather than +/// silently producing a grant that can never validate. +pub(crate) fn parse_secret(value: &str) -> Result { + let secret = GrantSecret::decode(value)?; + if secret.0.iter().all(|byte| *byte == 0) { + bail!("refusing an all-zero grant secret"); + } + Ok(secret) +} diff --git a/crates/vnidrop/src/lib.rs b/crates/vnidrop/src/lib.rs index 1ed14d8..7097823 100644 --- a/crates/vnidrop/src/lib.rs +++ b/crates/vnidrop/src/lib.rs @@ -4,6 +4,7 @@ mod approval; mod error; mod event_hub; mod filesystem; +mod grant; mod handshake; mod logging; mod repository; diff --git a/crates/vnidrop/src/tests.rs b/crates/vnidrop/src/tests.rs index 75e095a..2bf5c40 100644 --- a/crates/vnidrop/src/tests.rs +++ b/crates/vnidrop/src/tests.rs @@ -4,6 +4,8 @@ mod access_policy_tests; mod error_tests; #[path = "tests/filesystem.rs"] mod filesystem_tests; +#[path = "tests/grant.rs"] +mod grant_tests; #[path = "tests/handshake.rs"] mod handshake_tests; #[path = "tests/limits.rs"] diff --git a/crates/vnidrop/src/tests/grant.rs b/crates/vnidrop/src/tests/grant.rs new file mode 100644 index 0000000..74f45ee --- /dev/null +++ b/crates/vnidrop/src/tests/grant.rs @@ -0,0 +1,241 @@ +use crate::grant::{ + parse_secret, prove, Challenge, GrantId, GrantLifetime, GrantRejection, GrantSecret, + IssuedGrant, +}; + +const ISSUER: &str = "issuer-endpoint"; +const HOLDER: &str = "holder-endpoint"; +const DAY_MS: i64 = 24 * 60 * 60 * 1_000; + +fn issued(now_ms: i64) -> IssuedGrant { + IssuedGrant::mint(HOLDER.to_string(), now_ms, GrantLifetime::default()) +} + +fn accept_with( + grant: &IssuedGrant, + challenge: &Challenge, + remote_endpoint_id: &str, + now_ms: i64, +) -> Result, GrantRejection> { + let proof = prove( + grant.grant_id, + &grant.secret, + challenge, + ISSUER, + remote_endpoint_id, + ); + grant.accept( + &proof, + challenge, + ISSUER, + remote_endpoint_id, + now_ms, + GrantLifetime::default(), + ) +} + +#[test] +fn accepts_a_valid_proof_and_returns_the_renewed_deadline() { + let now = 1_700_000_000_000; + let grant = issued(now); + let challenge = Challenge::generate(); + + let renewed = accept_with(&grant, &challenge, HOLDER, now + DAY_MS).expect("proof accepted"); + + assert_eq!(renewed, Some(now + DAY_MS + 90 * DAY_MS)); +} + +#[test] +fn renewal_extends_past_the_original_expiry() { + let now = 1_700_000_000_000; + let grant = issued(now); + let original = grant.expires_at.expect("default lifetime expires"); + + // Used one day before lapsing: the new deadline must be later than the old. + let use_at = original - DAY_MS; + let renewed = accept_with(&grant, &Challenge::generate(), HOLDER, use_at) + .expect("proof accepted") + .expect("renewed deadline"); + + assert!(renewed > original); +} + +#[test] +fn rejects_a_proof_bound_to_a_different_challenge() { + let now = 1_700_000_000_000; + let grant = issued(now); + let captured = Challenge::from_bytes([7u8; 32]); + let proof = prove(grant.grant_id, &grant.secret, &captured, ISSUER, HOLDER); + + // Replaying a captured proof against a fresh challenge must fail. + let outcome = grant.accept( + &proof, + &Challenge::from_bytes([9u8; 32]), + ISSUER, + HOLDER, + now, + GrantLifetime::default(), + ); + + assert_eq!(outcome, Err(GrantRejection::BadProof)); +} + +#[test] +fn rejects_a_proof_from_an_endpoint_the_grant_was_not_issued_to() { + let now = 1_700_000_000_000; + let grant = issued(now); + + let outcome = accept_with(&grant, &Challenge::generate(), "someone-else", now); + + assert_eq!(outcome, Err(GrantRejection::WrongEndpoint)); +} + +#[test] +fn rejects_a_proof_replayed_against_a_different_issuer() { + let now = 1_700_000_000_000; + let grant = issued(now); + let challenge = Challenge::generate(); + let proof = prove( + grant.grant_id, + &grant.secret, + &challenge, + "other-issuer", + HOLDER, + ); + + let outcome = grant.accept( + &proof, + &challenge, + ISSUER, + HOLDER, + now, + GrantLifetime::default(), + ); + + assert_eq!(outcome, Err(GrantRejection::BadProof)); +} + +#[test] +fn rejects_a_revoked_grant_distinguishably() { + let now = 1_700_000_000_000; + let mut grant = issued(now); + grant.revoked_at = Some(now); + + // Revocation is reported as such so the peer can drop the dead entry. + assert_eq!( + accept_with(&grant, &Challenge::generate(), HOLDER, now), + Err(GrantRejection::Revoked) + ); +} + +#[test] +fn rejects_an_idle_grant_after_its_deadline() { + let now = 1_700_000_000_000; + let grant = issued(now); + let expires_at = grant.expires_at.expect("default lifetime expires"); + + assert_eq!( + accept_with(&grant, &Challenge::generate(), HOLDER, expires_at), + Ok(Some(expires_at + 90 * DAY_MS)), + "a grant is still usable on its deadline" + ); + assert_eq!( + accept_with(&grant, &Challenge::generate(), HOLDER, expires_at + 1), + Err(GrantRejection::Expired) + ); +} + +#[test] +fn rejects_a_proof_for_a_different_grant_id() { + let now = 1_700_000_000_000; + let grant = issued(now); + let other = issued(now); + let challenge = Challenge::generate(); + let proof = prove(other.grant_id, &other.secret, &challenge, ISSUER, HOLDER); + + let outcome = grant.accept( + &proof, + &challenge, + ISSUER, + HOLDER, + now, + GrantLifetime::default(), + ); + + assert_eq!(outcome, Err(GrantRejection::Unknown)); +} + +#[test] +fn never_lifetime_produces_no_deadline() { + let now = 1_700_000_000_000; + let grant = IssuedGrant::mint(HOLDER.to_string(), now, GrantLifetime::Never); + assert_eq!(grant.expires_at, None); + + let challenge = Challenge::generate(); + let proof = prove(grant.grant_id, &grant.secret, &challenge, ISSUER, HOLDER); + let renewed = grant + .accept( + &proof, + &challenge, + ISSUER, + HOLDER, + now + 10_000 * DAY_MS, + GrantLifetime::Never, + ) + .expect("proof accepted"); + + assert_eq!(renewed, None); + assert!(grant.is_active(now + 10_000 * DAY_MS)); +} + +#[test] +fn is_active_tracks_revocation_and_expiry() { + let now = 1_700_000_000_000; + let grant = issued(now); + assert!(grant.is_active(now)); + + let mut revoked = issued(now); + revoked.revoked_at = Some(now); + assert!(!revoked.is_active(now)); + + let expires_at = grant.expires_at.expect("default lifetime expires"); + assert!(!grant.is_active(expires_at + 1)); +} + +#[test] +fn grant_ids_and_secrets_round_trip_through_storage_encoding() { + let id = GrantId::generate(); + assert_eq!(GrantId::decode(&id.encode()).expect("decodes"), id); + + let secret = GrantSecret::generate(); + assert_eq!(parse_secret(&secret.encode()).expect("decodes"), secret); +} + +#[test] +fn rejects_malformed_or_degenerate_stored_secrets() { + assert!(parse_secret("not-hex").is_err()); + assert!(parse_secret("aabb").is_err(), "wrong length"); + assert!( + parse_secret(&"00".repeat(32)).is_err(), + "an all-zero secret means corrupt storage, not a usable grant" + ); +} + +#[test] +fn secrets_are_redacted_in_debug_output() { + let secret = GrantSecret::generate(); + let rendered = format!("{secret:?}"); + + assert!(!rendered.contains(&secret.encode())); + assert_eq!(rendered, "GrantSecret(redacted)"); +} + +#[test] +fn generated_grants_are_unique() { + let now = 1_700_000_000_000; + let first = issued(now); + let second = issued(now); + + assert_ne!(first.grant_id, second.grant_id); + assert_ne!(first.secret, second.secret); +}