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.
This commit is contained in:
2026-08-06 16:01:20 +02:00
parent 0388422318
commit 4cfee786fc
6 changed files with 578 additions and 0 deletions

1
Cargo.lock generated
View File

@@ -5227,6 +5227,7 @@ dependencies = [
"data-encoding",
"futures",
"futures-lite",
"getrandom 0.3.4",
"iroh",
"iroh-blobs",
"iroh-relay",

View File

@@ -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"

332
crates/vnidrop/src/grant.rs Normal file
View File

@@ -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<Self> {
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<Self> {
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<i64>,
pub(crate) revoked_at: Option<i64>,
}
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<Option<i64>, 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<i64> {
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<u8>, 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<const N: usize>() -> [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<GrantSecret> {
let secret = GrantSecret::decode(value)?;
if secret.0.iter().all(|byte| *byte == 0) {
bail!("refusing an all-zero grant secret");
}
Ok(secret)
}

View File

@@ -4,6 +4,7 @@ mod approval;
mod error;
mod event_hub;
mod filesystem;
mod grant;
mod handshake;
mod logging;
mod repository;

View File

@@ -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"]

View File

@@ -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<Option<i64>, 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);
}