use chrono::{DateTime, Duration, Utc};
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
use sha2::{Digest, Sha256};
use tokio::sync::{Mutex, MutexGuard};
use tracing::warn;
use trust_tasks_rs::TrustTask;
use trust_tasks_rs::specs::auth::step_up::approver::attest::v0_1 as attest;
use vti_common::error::AppError;
use vti_common::store::KeyspaceHandle;
use crate::server::AppState;
pub const MAX_PER_SUBJECT: usize = 5;
pub const ATTEST_TYPE: &str = <attest::Payload as trust_tasks_rs::Payload>::TYPE_URI;
static LOCK: Mutex<()> = Mutex::const_new(());
static SPENT_LOCK: Mutex<()> = Mutex::const_new(());
pub(crate) async fn lock() -> MutexGuard<'static, ()> {
LOCK.lock().await
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum EnrolledVia {
Install,
Invite,
SelfService,
Offline,
}
impl EnrolledVia {
pub fn as_str(self) -> &'static str {
match self {
Self::Install => "install",
Self::Invite => "invite",
Self::SelfService => "selfService",
Self::Offline => "offline",
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ApproverRecord {
pub approver_did: String,
pub subject: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub label: Option<String>,
pub enrolled_at: DateTime<Utc>,
pub enrolled_via: EnrolledVia,
pub anchor: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub last_used_at: Option<DateTime<Utc>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub revoked_at: Option<DateTime<Utc>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub revoked_by: Option<String>,
}
impl ApproverRecord {
pub fn is_live(&self) -> bool {
self.revoked_at.is_none()
}
pub fn to_wire(&self) -> Value {
let mut v = json!({
"approverDid": self.approver_did,
"subject": self.subject,
"enrolledAt": self.enrolled_at,
"enrolledVia": self.enrolled_via.as_str(),
});
if let Some(label) = &self.label {
v["label"] = json!(label);
}
if let Some(at) = self.last_used_at {
v["lastUsedAt"] = json!(at);
}
v
}
}
fn approver_key(did: &str) -> String {
format!("approver:{did}")
}
fn subject_key(did: &str) -> String {
format!("subject:{did}")
}
fn spent_key(id: &str) -> String {
format!("spent:{}", hex::encode(Sha256::digest(id.as_bytes())))
}
pub async fn get(
ks: &KeyspaceHandle,
approver_did: &str,
) -> Result<Option<ApproverRecord>, AppError> {
ks.get(approver_key(approver_did)).await
}
pub async fn stored_live(
ks: &KeyspaceHandle,
subject: &str,
) -> Result<Vec<ApproverRecord>, AppError> {
let dids: Vec<String> = ks.get(subject_key(subject)).await?.unwrap_or_default();
let mut out = Vec::with_capacity(dids.len());
for did in dids {
if let Some(r) = get(ks, &did).await?
&& r.is_live()
&& r.subject == subject
{
out.push(r);
}
}
out.sort_by_key(|r| std::cmp::Reverse(r.enrolled_at));
Ok(out)
}
pub async fn live_approvers(
state: &AppState,
subject: &str,
) -> Result<Vec<ApproverRecord>, AppError> {
if !crate::git_ns::ops::standing(state, subject).await?.member {
return Ok(Vec::new());
}
let mut out = Vec::new();
for r in stored_live(&state.step_up_approvers_ks, subject).await? {
match check_distinct(state, subject, &r.approver_did).await? {
Ok(()) => out.push(r),
Err(why) => warn!(
%subject,
approver = %r.approver_did,
why,
security_alert = true,
"a step-up approver is no longer distinct from its subject's keys; it does not count"
),
}
}
Ok(out)
}
pub fn ed25519_did_key(did: &str) -> Option<[u8; 32]> {
let mb = did.strip_prefix("did:key:")?;
if !mb.starts_with("z6Mk") || mb.contains('#') {
return None;
}
let (base, bytes) = multibase::decode(mb).ok()?;
if base != multibase::Base::Base58Btc || bytes.len() != 34 || bytes[..2] != [0xed, 0x01] {
return None;
}
bytes[2..].try_into().ok()
}
async fn subject_keys(state: &AppState, subject: &str) -> Result<Vec<Vec<u8>>, AppError> {
if subject.starts_with("did:key:") {
let mb = subject.trim_start_matches("did:key:");
let (_, bytes) = multibase::decode(mb)
.map_err(|e| AppError::Validation(format!("subject did:key: {e}")))?;
return Ok(vec![bytes.get(2..).unwrap_or_default().to_vec()]);
}
let resolver = state.did_resolver.as_ref().ok_or_else(|| {
AppError::Internal(format!(
"no DID resolver is configured, so {subject}'s document cannot be read"
))
})?;
let resolved = resolver
.resolve(subject)
.await
.map_err(|e| AppError::Internal(format!("{subject} did not resolve: {e}")))?;
Ok(resolved
.doc
.verification_method
.iter()
.filter_map(|vm| vm.get_public_key_bytes().ok())
.collect())
}
pub async fn check_distinct(
state: &AppState,
subject: &str,
approver_did: &str,
) -> Result<Result<(), &'static str>, AppError> {
let Some(key) = ed25519_did_key(approver_did) else {
return Ok(Err("not an Ed25519 did:key"));
};
if approver_did == subject {
return Ok(Err("the subject's own DID"));
}
if crate::acl::console_key::get_delegation(&state.console_keys_ks, approver_did)
.await?
.is_some()
{
return Ok(Err("a console signing key"));
}
if crate::acl::get_acl_entry(&state.acl_ks, approver_did)
.await?
.is_some()
{
return Ok(Err("holds standing of its own"));
}
match subject_keys(state, subject).await {
Ok(keys) if keys.iter().any(|k| k.as_slice() == key.as_slice()) => {
Ok(Err("a verification method of the subject's DID document"))
}
Ok(_) => Ok(Ok(())),
Err(e) => {
warn!(%subject, error = %e, "subject DID document unreadable; approver treated as not distinct");
Ok(Err("the subject's DID document could not be read"))
}
}
}
#[derive(Debug)]
pub enum BindError {
AlreadyBound,
TooMany,
ReplaceNotFound,
Internal(AppError),
}
impl From<AppError> for BindError {
fn from(e: AppError) -> Self {
Self::Internal(e)
}
}
pub async fn check_bindable(
ks: &KeyspaceHandle,
approver_did: &str,
subject: &str,
replaces: Option<&str>,
) -> Result<(), BindError> {
if get(ks, approver_did).await?.is_some() {
return Err(BindError::AlreadyBound);
}
let live = stored_live(ks, subject).await?;
if let Some(r) = replaces
&& !live.iter().any(|a| a.approver_did == r)
{
return Err(BindError::ReplaceNotFound);
}
let after = live.len() - usize::from(replaces.is_some());
if after >= MAX_PER_SUBJECT {
return Err(BindError::TooMany);
}
Ok(())
}
pub(crate) async fn write_binding(
ks: &KeyspaceHandle,
record: &ApproverRecord,
replaces: Option<&str>,
replaced_by: &str,
) -> Result<(), BindError> {
check_bindable(ks, &record.approver_did, &record.subject, replaces).await?;
if let Some(old) = replaces {
tombstone(ks, &record.subject, old, replaced_by).await?;
}
ks.insert(approver_key(&record.approver_did), record)
.await?;
let mut dids: Vec<String> = ks
.get(subject_key(&record.subject))
.await?
.unwrap_or_default();
dids.retain(|d| d != &record.approver_did);
dids.push(record.approver_did.clone());
ks.insert(subject_key(&record.subject), &dids).await?;
Ok(())
}
pub(crate) async fn tombstone(
ks: &KeyspaceHandle,
subject: &str,
approver_did: &str,
by: &str,
) -> Result<Option<ApproverRecord>, AppError> {
let Some(mut r) = get(ks, approver_did).await? else {
return Ok(None);
};
if r.subject != subject || !r.is_live() {
return Ok(None);
}
let before = r.clone();
r.revoked_at = Some(Utc::now());
r.revoked_by = Some(by.to_string());
ks.insert(approver_key(approver_did), &r).await?;
let mut dids: Vec<String> = ks.get(subject_key(subject)).await?.unwrap_or_default();
dids.retain(|d| d != approver_did);
ks.insert(subject_key(subject), &dids).await?;
Ok(Some(before))
}
pub async fn record_use(ks: &KeyspaceHandle, approver_did: &str) -> Result<(), AppError> {
let _guard = lock().await;
if let Some(mut r) = get(ks, approver_did).await?
&& r.is_live()
{
r.last_used_at = Some(Utc::now());
ks.insert(approver_key(approver_did), &r).await?;
}
Ok(())
}
#[derive(Debug, Clone, Copy)]
pub enum ExpectedApprover<'a> {
BoundAmong(&'a [String]),
Enrolling(&'a str),
}
#[derive(Debug, Clone, Copy)]
pub struct ExpectedStatement<'a> {
pub purpose: attest::PayloadPurpose,
pub subject: &'a str,
pub challenge: &'a str,
pub bound_to: &'a str,
pub not_before: DateTime<Utc>,
pub not_after: DateTime<Utc>,
pub approver: ExpectedApprover<'a>,
}
#[derive(Debug)]
pub enum StatementError {
Invalid(&'static str),
NotBound,
Internal(AppError),
}
impl From<AppError> for StatementError {
fn from(e: AppError) -> Self {
Self::Internal(e)
}
}
#[derive(Debug, Clone)]
pub struct VerifiedApproverStatement {
approver_did: String,
subject: String,
purpose: attest::PayloadPurpose,
statement_id: String,
issued_at: DateTime<Utc>,
}
impl VerifiedApproverStatement {
pub fn approver_did(&self) -> &str {
&self.approver_did
}
pub fn subject(&self) -> &str {
&self.subject
}
pub fn purpose(&self) -> attest::PayloadPurpose {
self.purpose
}
pub fn statement_id(&self) -> &str {
&self.statement_id
}
pub fn issued_at(&self) -> DateTime<Utc> {
self.issued_at
}
}
fn ct_eq(a: &str, b: &str) -> bool {
let (a, b) = (a.as_bytes(), b.as_bytes());
if a.len() != b.len() {
return false;
}
a.iter().zip(b).fold(0u8, |acc, (x, y)| acc | (x ^ y)) == 0
}
pub async fn verify_statement(
state: &AppState,
statement: &Value,
expected: &ExpectedStatement<'_>,
) -> Result<VerifiedApproverStatement, StatementError> {
use trust_tasks_rs::validate::ValidatedPayload as _;
let invalid = |why: &'static str| {
warn!(subject = %expected.subject, why, "step-up approver statement refused");
StatementError::Invalid(why)
};
if !statement.is_object() {
return Err(invalid("not an object"));
}
let doc: TrustTask<Value> =
serde_json::from_value(statement.clone()).map_err(|_| invalid("not a Trust Task"))?;
if doc.type_uri.to_string() != ATTEST_TYPE {
return Err(invalid("not an attest/0.1 document"));
}
let Some(issuer) = doc.issuer.clone() else {
return Err(invalid("no issuer"));
};
if ed25519_did_key(&issuer).is_none() {
return Err(invalid("issuer is not an Ed25519 did:key"));
}
if statement
.pointer("/proof/proofPurpose")
.and_then(Value::as_str)
!= Some("authentication")
{
return Err(invalid("proof not made for authentication"));
}
let signer = vti_common::auth::verify_trust_task_proof_value(
statement,
&vti_common::auth::TrustTaskVmResolver::did_key_only(),
)
.await
.map_err(|e| {
warn!(error = %e, cause = e.cause().unwrap_or_default(), "statement proof did not verify");
invalid("proof does not verify")
})?;
if signer != issuer {
return Err(invalid("proof not by the issuer"));
}
let vtc_did =
state.config.read().await.vtc_did.clone().ok_or_else(|| {
AppError::Config("this VTC has no DID; it cannot be an audience".into())
})?;
let now = Utc::now();
let window = vti_common::trust_task::acceptance::VTI_ACCEPTANCE_WINDOW;
if doc
.validate_freshness(now, &window.freshness_policy().requiring_issued_at())
.is_err()
{
return Err(invalid("outside its acceptance window"));
}
if doc.validate_basic(now, &vtc_did).is_err() || doc.recipient.as_deref() != Some(&vtc_did) {
return Err(invalid("recipient is not this VTC"));
}
attest::Payload::validate_value(&doc.payload).map_err(|_| invalid("payload schema"))?;
let payload: attest::Payload =
serde_json::from_value(doc.payload.clone()).map_err(|_| invalid("payload shape"))?;
if payload.audience.as_str() != vtc_did {
return Err(invalid("audience is not this VTC"));
}
if payload.purpose != expected.purpose {
return Err(invalid("wrong purpose"));
}
if payload.subject.as_str() != expected.subject {
return Err(invalid("subject differs from the pending record"));
}
if !ct_eq(payload.challenge.as_str(), expected.challenge) {
return Err(invalid("challenge differs from the pending record"));
}
if payload.bound_to.as_str() != expected.bound_to {
return Err(invalid("boundTo differs from the pending record"));
}
let issued_at = doc.issued_at.ok_or_else(|| invalid("no issuedAt"))?;
let skew = Duration::seconds(60);
if issued_at < expected.not_before - skew || issued_at > expected.not_after + skew {
return Err(invalid("issued outside the pending record's lifetime"));
}
if issuer == expected.subject {
return Err(invalid("issuer is the subject"));
}
match expected.approver {
ExpectedApprover::Enrolling(approver) => {
if issuer != approver {
return Err(invalid("issuer is not the approver being enrolled"));
}
}
ExpectedApprover::BoundAmong(offered) => {
if !offered.iter().any(|d| d == &issuer) {
return Err(StatementError::NotBound);
}
let bound = live_approvers(state, expected.subject).await?;
if !bound.iter().any(|r| r.approver_did == issuer) {
warn!(
subject = %expected.subject,
approver = %issuer,
security_alert = true,
"a statement by a DID that is not a live approver of its subject"
);
return Err(StatementError::NotBound);
}
}
}
let ks = &state.step_up_approvers_ks;
let key = spent_key(&doc.id);
{
let _guard = SPENT_LOCK.lock().await;
if let Some(until) = ks.get::<i64>(key.clone()).await?
&& until > now.timestamp()
{
return Err(invalid("statement id already spent"));
}
let until = issued_at + window.max_age + window.clock_skew + skew;
ks.insert(key, &until.timestamp()).await?;
}
Ok(VerifiedApproverStatement {
approver_did: issuer,
subject: expected.subject.to_string(),
purpose: payload.purpose,
statement_id: doc.id.clone(),
issued_at,
})
}
#[cfg(any(test, feature = "test-support"))]
pub async fn bind_for_test(
state: &AppState,
subject: &str,
approver_did: &str,
) -> Result<(), AppError> {
let _guard = lock().await;
write_binding(
&state.step_up_approvers_ks,
&ApproverRecord {
approver_did: approver_did.to_string(),
subject: subject.to_string(),
label: Some("test".into()),
enrolled_at: Utc::now(),
enrolled_via: EnrolledVia::Invite,
anchor: "test".into(),
last_used_at: None,
revoked_at: None,
revoked_by: None,
},
None,
subject,
)
.await
.map_err(|e| AppError::Internal(format!("bind_for_test: {e:?}")))
}
pub(crate) async fn sweep_spent(
ks: &KeyspaceHandle,
now: DateTime<Utc>,
) -> Result<usize, AppError> {
let mut removed = 0;
for (key, value) in ks.prefix_iter_raw(b"spent:".to_vec()).await? {
let lapsed = serde_json::from_slice::<i64>(&value).map_or(true, |t| t <= now.timestamp());
if lapsed {
ks.remove(key).await?;
removed += 1;
}
}
Ok(removed)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn only_an_ed25519_did_key_is_an_approver() {
let party = vti_rooms_dtg::test_support::Party::new();
assert!(ed25519_did_key(&party.did).is_some());
assert!(ed25519_did_key("did:web:example.com").is_none());
assert!(ed25519_did_key(&format!("{}#key-1", party.did)).is_none());
assert!(
ed25519_did_key("did:key:z6LSbysY2xFMRpGMhb7tFTLMpeuPRaqaWM1yECx2AtzE3KCc").is_none()
);
}
#[test]
fn the_constant_time_compare_is_equality() {
assert!(ct_eq("abcdef", "abcdef"));
assert!(!ct_eq("abcdef", "abcdeg"));
assert!(!ct_eq("abc", "abcd"));
}
#[test]
fn the_wire_record_is_the_shared_approver_shape() {
let r = ApproverRecord {
approver_did: "did:key:z6MkpTHR8VNsBxYAAWHut2Geadd9jSwuBV8xRoAnwWsdvktH".into(),
subject: "did:key:z6MkSubject".into(),
label: Some("laptop".into()),
enrolled_at: Utc::now(),
enrolled_via: EnrolledVia::SelfService,
anchor: "webauthn:c0ffee".into(),
last_used_at: None,
revoked_at: None,
revoked_by: None,
};
let v = r.to_wire();
assert_eq!(v["enrolledVia"], "selfService");
assert!(v.get("anchor").is_none(), "the anchor is never on the wire");
}
}