mirror of
https://github.com/sudosylabs/vnidrop.git
synced 2026-08-07 11:19:58 +02:00
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:
@@ -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
332
crates/vnidrop/src/grant.rs
Normal 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)
|
||||
}
|
||||
@@ -4,6 +4,7 @@ mod approval;
|
||||
mod error;
|
||||
mod event_hub;
|
||||
mod filesystem;
|
||||
mod grant;
|
||||
mod handshake;
|
||||
mod logging;
|
||||
mod repository;
|
||||
|
||||
@@ -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"]
|
||||
|
||||
241
crates/vnidrop/src/tests/grant.rs
Normal file
241
crates/vnidrop/src/tests/grant.rs
Normal 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);
|
||||
}
|
||||
Reference in New Issue
Block a user