use base64::Engine;
use base64::engine::general_purpose::URL_SAFE_NO_PAD as B64;
use chrono::{DateTime, Utc};
use rand::Rng;
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
use sha2::{Digest, Sha256};
use tracing::{info, warn};
use trust_tasks_rs::specs::auth::step_up::approver::attest::v0_1 as attest;
use trust_tasks_rs::specs::auth::step_up::approver::enroll::v0_1 as enroll;
use trust_tasks_rs::specs::auth::step_up::approver::invite::v0_1 as invite;
use trust_tasks_rs::specs::auth::step_up::approver::list::v0_1 as list;
use trust_tasks_rs::specs::auth::step_up::approver::redeem::finish::v0_1 as redeem_finish;
use trust_tasks_rs::specs::auth::step_up::approver::redeem::start::v0_1 as redeem_start;
use trust_tasks_rs::specs::auth::step_up::approver::revoke::v0_1 as revoke;
use trust_tasks_rs::specs::vtc::install::claim::finish::v0_3 as claim_finish;
use trust_tasks_rs::specs::vtc::install::claim::start::v0_3 as claim_start;
use uuid::Uuid;
use vti_common::audit::{AuditEvent, StepUpApproverData};
use vti_common::auth::extractor::AuthClaims;
use vti_common::error::AppError;
use vti_common::store::KeyspaceHandle;
use crate::acl::approver::{
self, ApproverRecord, BindError, EnrolledVia, ExpectedApprover, ExpectedStatement,
StatementError,
};
use crate::acl::bound_step_up::{self, ApproveRequest};
use crate::auth::session::now_epoch;
use crate::install::claim_secret;
use crate::server::AppState;
pub const MAX_WRONG_CODES: u32 = 5;
pub const CEREMONY_TTL_SECS: u64 = 300;
pub const DEFAULT_INVITE_TTL_SECS: u64 = 900;
pub const MAX_INVITE_TTL_SECS: u64 = 24 * 3600;
pub const ENROL_PATH: &str = "/admin/enrol-approver";
pub const ENROLL_TYPE: &str = <enroll::Payload as trust_tasks_rs::Payload>::TYPE_URI;
#[derive(Debug)]
pub enum ApproverTaskError {
Refused {
code: &'static str,
message: String,
details: Option<Value>,
},
StepUp(Box<ApproveRequest>),
App(AppError),
}
impl From<AppError> for ApproverTaskError {
fn from(e: AppError) -> Self {
Self::App(e)
}
}
fn refused(
code: trust_tasks_rs::DeclaredErrorCode,
message: impl Into<String>,
) -> ApproverTaskError {
ApproverTaskError::Refused {
code: code.code,
message: message.into(),
details: None,
}
}
type Result<T, E = ApproverTaskError> = std::result::Result<T, E>;
#[derive(Serialize, Deserialize)]
struct Invite {
invite_id: String,
subject: String,
invited_by: String,
via: EnrolledVia,
#[serde(default, skip_serializing_if = "Option::is_none")]
label: Option<String>,
code_hash: String,
expires_at: u64,
wrong_codes: u32,
#[serde(default)]
voided: bool,
}
impl std::fmt::Debug for Invite {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("Invite")
.field("invite_id", &self.invite_id)
.field("subject", &self.subject)
.field("invited_by", &self.invited_by)
.field("expires_at", &self.expires_at)
.field("wrong_codes", &self.wrong_codes)
.field("voided", &self.voided)
.finish_non_exhaustive()
}
}
#[derive(Serialize, Deserialize)]
struct RedeemCeremony {
invite_key: String,
subject: String,
challenge: String,
created_at: u64,
expires_at: u64,
}
impl std::fmt::Debug for RedeemCeremony {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("RedeemCeremony")
.field("invite_key", &"<redacted>")
.field("subject", &self.subject)
.field("expires_at", &self.expires_at)
.finish_non_exhaustive()
}
}
#[derive(Debug, Serialize, Deserialize)]
struct InstallClaim {
jti: Uuid,
admin_did: String,
challenge: String,
created_at: u64,
expires_at: u64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub(crate) struct PendingInstallBinding {
pub admin_did: String,
pub approver_did: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub label: Option<String>,
pub expires_at: u64,
}
fn invite_key(token: &str) -> String {
format!("invite:{}", hex::encode(Sha256::digest(token.as_bytes())))
}
fn redeem_key(id: &str) -> String {
format!("redeem:{id}")
}
fn install_wrong_key(jti: &Uuid) -> String {
format!("install_wrong:{jti}")
}
async fn count_wrong_install_code(state: &AppState, jti: &Uuid) -> Result<u32, AppError> {
let _guard = crate::acl::approver::lock().await;
let key = install_wrong_key(jti);
let wrong = state
.step_up_approvers_ks
.get::<u32>(key.clone())
.await?
.unwrap_or(0)
.saturating_add(1);
state.step_up_approvers_ks.insert(key, &wrong).await?;
Ok(wrong)
}
fn claim_key(id: &str) -> String {
format!("claim:{id}")
}
fn pending_key(jti: &str) -> String {
format!("pending:{jti}")
}
fn epoch_to_utc(t: u64) -> DateTime<Utc> {
DateTime::<Utc>::from_timestamp(t as i64, 0).unwrap_or_default()
}
fn random_b64(bytes: usize) -> String {
let mut raw = vec![0u8; bytes];
rand::rng().fill_bytes(&mut raw);
B64.encode(raw)
}
fn random_hex(bytes: usize) -> String {
let mut raw = vec![0u8; bytes];
rand::rng().fill_bytes(&mut raw);
hex::encode(raw)
}
fn normalise_code(code: &str) -> String {
code.chars()
.filter(|c| !c.is_whitespace() && *c != '-')
.map(|c| c.to_ascii_uppercase())
.collect()
}
async fn vtc_did(state: &AppState) -> Result<String, AppError> {
state
.config
.read()
.await
.vtc_did
.clone()
.ok_or_else(|| AppError::Config("this VTC has no DID configured yet".into()))
}
async fn audit(state: &AppState, actor: &str, data: StepUpApproverData) -> Result<(), AppError> {
if let Some(writer) = state.audit_writer.as_ref() {
writer
.write(
actor,
Some(&data.subject.clone()),
AuditEvent::StepUpApproverChanged(data),
)
.await?;
}
Ok(())
}
fn stage(stage: &str, subject: &str) -> StepUpApproverData {
StepUpApproverData {
stage: stage.into(),
subject: subject.into(),
approver_did: None,
enrolled_via: None,
anchor: None,
replaced: None,
reason: None,
expires_at: None,
}
}
async fn is_delegated_key(state: &AppState, signer: &str) -> Result<bool, AppError> {
Ok(
crate::acl::console_key::get_delegation(&state.console_keys_ks, signer)
.await?
.is_some(),
)
}
pub(crate) async fn admin_covers(
state: &AppState,
actor: &AuthClaims,
subject: &str,
) -> Result<bool, AppError> {
let Some(actor_entry) = crate::acl::get_acl_entry(&state.acl_ks, &actor.did).await? else {
return Ok(false);
};
if !actor_entry.can(crate::acl::Capability::MembersManage, None) {
return Ok(false);
}
let entry = crate::acl::get_acl_entry(&state.acl_ks, subject)
.await?
.filter(|e| !e.is_expired(now_epoch()));
Ok(match entry {
Some(e) if e.is_administrator() => {
crate::acl::granting::covers_entry(&actor_entry, &e, now_epoch())
}
_ => true,
})
}
async fn after_binding(state: &AppState, subject: &str) -> Result<(), AppError> {
crate::step_up_passkey::revoke_session_elevation(&state.sessions_ks, subject).await
}
fn bind_refusal(
e: BindError,
already: trust_tasks_rs::DeclaredErrorCode,
too_many: trust_tasks_rs::DeclaredErrorCode,
replace: Option<trust_tasks_rs::DeclaredErrorCode>,
) -> ApproverTaskError {
match e {
BindError::AlreadyBound => refused(
already,
"that approver DID is already bound, or was bound and revoked; generate a new one",
),
BindError::TooMany => refused(
too_many,
format!(
"the subject already holds {} live approvers; revoke one first",
approver::MAX_PER_SUBJECT
),
),
BindError::ReplaceNotFound => match replace {
Some(code) => refused(code, "no such approver of this subject"),
None => ApproverTaskError::App(AppError::Internal("unexpected replace".into())),
},
BindError::Internal(e) => ApproverTaskError::App(e),
}
}
pub async fn check_invite(
state: &AppState,
admin_did: &str,
payload: &invite::Payload,
) -> Result<()> {
use invite::error_codes as codes;
if state.public_url.is_none() {
return Err(AppError::Config(
"public_url is not configured; an invite has no URL to carry".into(),
)
.into());
}
if payload.subject.as_str() == admin_did {
return Err(refused(
codes::SELF_INVITE,
"an administrator does not invite themselves; ask another administrator",
));
}
if !crate::git_ns::ops::standing(state, admin_did)
.await?
.community_admin
{
return Err(ApproverTaskError::Refused {
code: "permissionDenied",
message: "only a community administrator invites a member to enrol a step-up \
approver"
.into(),
details: None,
});
}
if !crate::git_ns::ops::standing(state, payload.subject.as_str())
.await?
.member
{
return Err(refused(
codes::SUBJECT_UNKNOWN,
"the subject is not a current member of this community",
));
}
let ttl = payload.ttl.map_or(DEFAULT_INVITE_TTL_SECS, |t| t.get());
if ttl > MAX_INVITE_TTL_SECS {
return Err(ApproverTaskError::Refused {
code: codes::TTL_TOO_LONG.code,
message: format!("ttl must be at most {MAX_INVITE_TTL_SECS} seconds"),
details: Some(json!({ "maxTtl": MAX_INVITE_TTL_SECS })),
});
}
Ok(())
}
pub struct MintedInvite {
pub invite_id: String,
pub token: String,
pub url: String,
pub claim_code: String,
pub expires_at: u64,
}
async fn mint_invite(
ks: &KeyspaceHandle,
public_url: &str,
subject: &str,
invited_by: &str,
via: EnrolledVia,
label: Option<String>,
ttl: u64,
) -> Result<MintedInvite, AppError> {
let token = format!("sua_{}", random_b64(32));
let claim_code = claim_secret::generate();
let code = claim_code.clone();
let code_hash = tokio::task::spawn_blocking(move || claim_secret::hash(&code))
.await
.map_err(|e| AppError::Internal(format!("claim-code hash task failed: {e}")))??;
let invite_id = format!("sui_{}", random_hex(8));
let expires_at = now_epoch().saturating_add(ttl);
ks.insert(
invite_key(&token),
&Invite {
invite_id: invite_id.clone(),
subject: subject.to_string(),
invited_by: invited_by.to_string(),
via,
label,
code_hash,
expires_at,
wrong_codes: 0,
voided: false,
},
)
.await?;
let url = format!(
"{}{ENROL_PATH}#token={token}",
public_url.trim_end_matches('/')
);
Ok(MintedInvite {
invite_id,
token,
url,
claim_code,
expires_at,
})
}
pub async fn issue_invite(
state: &AppState,
admin_did: &str,
payload: &invite::Payload,
) -> Result<invite::Response> {
check_invite(state, admin_did, payload).await?;
let public_url = state.public_url.as_deref().unwrap_or_default();
let subject = payload.subject.to_string();
let ttl = payload.ttl.map_or(DEFAULT_INVITE_TTL_SECS, |t| t.get());
let minted = mint_invite(
&state.step_up_approvers_ks,
public_url,
&subject,
admin_did,
EnrolledVia::Invite,
payload.label.as_ref().map(|l| l.to_string()),
ttl,
)
.await?;
audit(
state,
admin_did,
StepUpApproverData {
enrolled_via: Some(EnrolledVia::Invite.as_str().into()),
anchor: Some(admin_did.into()),
expires_at: Some(epoch_to_utc(minted.expires_at)),
..stage("invited", &subject)
},
)
.await?;
info!(admin = %admin_did, %subject, invite_id = %minted.invite_id, "step-up approver invite issued");
serde_json::from_value(json!({
"inviteId": minted.invite_id,
"url": minted.url,
"claimCode": minted.claim_code,
"expiresAt": epoch_to_utc(minted.expires_at),
}))
.map_err(|e| AppError::Internal(format!("invite response: {e}")).into())
}
pub(crate) async fn invite_new_administrator(
state: &AppState,
creator: &str,
subject: &str,
) -> Result<Option<Value>, AppError> {
if !crate::acl::approver::live_approvers(state, subject)
.await?
.is_empty()
{
return Ok(None);
}
let public_url = state.public_url.as_deref().unwrap_or_default();
let minted = mint_invite(
&state.step_up_approvers_ks,
public_url,
subject,
creator,
EnrolledVia::Invite,
Some("new administrator".into()),
DEFAULT_INVITE_TTL_SECS,
)
.await?;
audit(
state,
creator,
StepUpApproverData {
enrolled_via: Some(EnrolledVia::Invite.as_str().into()),
anchor: Some(creator.into()),
expires_at: Some(epoch_to_utc(minted.expires_at)),
..stage("invited", subject)
},
)
.await?;
info!(admin = %creator, %subject, invite_id = %minted.invite_id, "step-up approver invite issued for a new administrator");
Ok(Some(json!({
"inviteId": minted.invite_id,
"url": minted.url,
"claimCode": minted.claim_code,
"expiresAt": epoch_to_utc(minted.expires_at),
})))
}
pub async fn mint_offline_invite(
store: &vti_common::store::Store,
public_url: &str,
subject: &str,
ttl: u64,
) -> Result<MintedInvite, AppError> {
if !subject.starts_with("did:") {
return Err(AppError::Validation(format!(
"--did must be a DID (got {subject:?})"
)));
}
if ttl == 0 || ttl > MAX_INVITE_TTL_SECS {
return Err(AppError::Validation(format!(
"--ttl must be between 1 and {MAX_INVITE_TTL_SECS} seconds"
)));
}
let acl_ks = store.keyspace(crate::store::keyspaces::ACL)?;
if crate::acl::get_acl_entry(&acl_ks, subject)
.await?
.filter(|e| !e.is_expired(now_epoch()))
.is_none()
{
return Err(AppError::NotFound(format!(
"{subject} holds no ACL entry here; an approver answers only a member's step-ups \
(add them first with `vtc acl add`)"
)));
}
let ks = store.keyspace(crate::store::keyspaces::STEP_UP_APPROVERS)?;
let minted = mint_invite(
&ks,
public_url,
subject,
"host",
EnrolledVia::Offline,
None,
ttl,
)
.await?;
crate::install::record_offline_acl_write(
store,
"vtc admin enrol-approver",
"approverInvite",
subject,
None,
&[],
)
.await?;
Ok(minted)
}
pub async fn redeem_start(
state: &AppState,
signer: &str,
payload: &redeem_start::Payload,
) -> Result<redeem_start::Response> {
use redeem_start::error_codes as codes;
let not_invited = || {
refused(
codes::NOT_INVITED_SUBJECT,
"this redemption is not signed by the subject the invite was issued for, with a key \
of their own DID",
)
};
if is_delegated_key(state, signer).await? {
return Err(not_invited());
}
let ks = &state.step_up_approvers_ks;
let key = invite_key(payload.token.as_str());
let audience = vtc_did(state).await?;
let (subject, invite_expires) = {
let _guard = approver::lock().await;
let Some(mut invite) = ks.get::<Invite>(key.clone()).await? else {
let _ = tokio::task::spawn_blocking(|| claim_secret::hash("timing")).await;
return Err(refused(
codes::INVITE_NOT_FOUND,
"no such invite, or it was already redeemed",
));
};
if invite.voided {
return Err(refused(
codes::INVITE_VOIDED,
"this invite was voided after five wrong claim codes; ask the administrator for a \
new one",
));
}
if now_epoch() >= invite.expires_at {
ks.remove(key).await?;
return Err(refused(
codes::INVITE_EXPIRED,
"this invite has expired; ask the administrator for a new one",
));
}
if signer != invite.subject {
return Err(not_invited());
}
let supplied = normalise_code(payload.claim_code.as_str());
let stored = invite.code_hash.clone();
let code_ok = tokio::task::spawn_blocking(move || claim_secret::verify(&supplied, &stored))
.await
.map_err(|e| AppError::Internal(format!("claim-code verify task failed: {e}")))??;
if !code_ok {
invite.wrong_codes += 1;
warn!(subject = %invite.subject, wrong = invite.wrong_codes, "step-up approver invite: wrong claim code");
if invite.wrong_codes >= MAX_WRONG_CODES {
invite.voided = true;
ks.insert(key, &invite).await?;
audit(
state,
&invite.subject,
StepUpApproverData {
anchor: Some(invite.invited_by.clone()),
..stage("inviteVoided", &invite.subject)
},
)
.await?;
return Err(refused(
codes::INVITE_VOIDED,
"five wrong claim codes: this invite is void; ask the administrator for a new \
one",
));
}
let remaining = MAX_WRONG_CODES - invite.wrong_codes;
ks.insert(key, &invite).await?;
return Err(ApproverTaskError::Refused {
code: codes::CODE_MISMATCH.code,
message: "the claim code is wrong".into(),
details: Some(json!({ "attemptsRemaining": remaining })),
});
}
(invite.subject, invite.expires_at)
};
let enrollment_id = format!("enr_{}", random_hex(16));
let challenge = random_b64(32);
let now = now_epoch();
let expires_at = now.saturating_add(CEREMONY_TTL_SECS).min(invite_expires);
ks.insert(
redeem_key(&enrollment_id),
&RedeemCeremony {
invite_key: key,
subject,
challenge: challenge.clone(),
created_at: now,
expires_at,
},
)
.await?;
serde_json::from_value(json!({
"enrollmentId": enrollment_id,
"challenge": challenge,
"audience": audience,
"expiresAt": epoch_to_utc(expires_at),
}))
.map_err(|e| AppError::Internal(format!("redeem/start response: {e}")).into())
}
pub async fn redeem_finish(
state: &AppState,
signer: &str,
payload: &redeem_finish::Payload,
statement: &Value,
) -> Result<redeem_finish::Response> {
use redeem_finish::error_codes as codes;
let ks = &state.step_up_approvers_ks;
let not_found = || {
refused(
codes::ENROLLMENT_NOT_FOUND,
"no redemption in progress with this id, or its invite is no longer valid",
)
};
let enrollment_id = payload.enrollment_id.as_str();
let approver_did = payload.approver_did.to_string();
let _guard = approver::lock().await;
let Some(c) = ks.get::<RedeemCeremony>(redeem_key(enrollment_id)).await? else {
return Err(not_found());
};
if now_epoch() >= c.expires_at {
ks.remove(redeem_key(enrollment_id)).await?;
return Err(refused(
codes::ENROLLMENT_EXPIRED,
"this redemption lapsed; start again while the invite is valid",
));
}
if signer != c.subject || is_delegated_key(state, signer).await? {
return Err(refused(
codes::NOT_INVITED_SUBJECT,
"this finish is not signed by the subject the redemption was opened for",
));
}
let Some(invite) = ks.get::<Invite>(c.invite_key.clone()).await? else {
ks.remove(redeem_key(enrollment_id)).await?;
return Err(not_found());
};
if invite.voided || now_epoch() >= invite.expires_at || invite.subject != c.subject {
ks.remove(redeem_key(enrollment_id)).await?;
return Err(not_found());
}
let expected = ExpectedStatement {
purpose: attest::PayloadPurpose::Enrol,
subject: &c.subject,
challenge: &c.challenge,
bound_to: enrollment_id,
not_before: epoch_to_utc(c.created_at),
not_after: epoch_to_utc(c.expires_at),
approver: ExpectedApprover::Enrolling(&approver_did),
};
match approver::verify_statement(state, statement, &expected).await {
Ok(_) => {}
Err(StatementError::Internal(e)) => return Err(e.into()),
Err(_) => {
return Err(refused(
codes::STATEMENT_INVALID,
"the approver's enrolment statement is not valid for this redemption",
));
}
}
if let Err(why) = approver::check_distinct(state, &c.subject, &approver_did).await? {
warn!(subject = %c.subject, approver = %approver_did, why, "approver refused: not distinct");
return Err(refused(
codes::APPROVER_NOT_DISTINCT,
"that approver is a key the subject already signs with, or holds standing here; a \
step-up factor must be a key the subject does not already hold for signing",
));
}
if !crate::git_ns::ops::standing(state, &c.subject)
.await?
.member
{
ks.remove(c.invite_key.clone()).await?;
ks.remove(redeem_key(enrollment_id)).await?;
return Err(not_found());
}
let record = ApproverRecord {
approver_did: approver_did.clone(),
subject: c.subject.clone(),
label: payload
.label
.as_ref()
.map(|l| l.to_string())
.or(invite.label.clone()),
enrolled_at: Utc::now(),
enrolled_via: invite.via,
anchor: invite.invited_by.clone(),
last_used_at: None,
revoked_at: None,
revoked_by: None,
};
approver::write_binding(ks, &record, None, &c.subject)
.await
.map_err(|e| {
bind_refusal(
e,
codes::APPROVER_ALREADY_BOUND,
codes::TOO_MANY_APPROVERS,
None,
)
})?;
ks.remove(c.invite_key.clone()).await?;
ks.remove(redeem_key(enrollment_id)).await?;
audit(
state,
&c.subject,
StepUpApproverData {
approver_did: Some(approver_did.clone()),
enrolled_via: Some(invite.via.as_str().into()),
anchor: Some(invite.invited_by.clone()),
..stage("enrolled", &c.subject)
},
)
.await?;
after_binding(state, &c.subject).await?;
info!(subject = %c.subject, approver = %approver_did, via = invite.via.as_str(), "step-up approver bound by invite");
serde_json::from_value(json!({ "approver": record.to_wire() }))
.map_err(|e| AppError::Internal(format!("redeem/finish response: {e}")).into())
}
pub fn enrolment_terms(raw_payload: &Value) -> Value {
let mut terms = raw_payload.clone();
if let Some(obj) = terms.as_object_mut() {
obj.remove("statement");
}
terms
}
pub fn terms_digest(subject: &str, challenge: &str, terms: &Value) -> Result<String, AppError> {
let canonical = serde_json_canonicalizer::to_string(&json!({
"type": ENROLL_TYPE,
"subject": subject,
"challenge": challenge,
"terms": terms,
}))
.map_err(|e| AppError::Internal(format!("terms JCS canonicalization failed: {e}")))?;
let mut mh = vec![0x12, 0x20];
mh.extend_from_slice(&Sha256::digest(canonical.as_bytes()));
Ok(multibase::encode(multibase::Base::Base58Btc, mh))
}
pub async fn enroll(
state: &AppState,
signer: &str,
raw_payload: &Value,
payload: &enroll::Payload,
) -> Result<enroll::Response> {
use enroll::error_codes as codes;
if is_delegated_key(state, signer).await? {
return Err(refused(
codes::SUBJECT_MISMATCH,
"an approver enrolment must be signed by the subject's own DID, not a console \
signing key",
));
}
let subject = signer;
let factors = bound_step_up::factors_of(state, subject).await?;
if factors.is_empty() || !crate::git_ns::ops::standing(state, subject).await?.member {
return Err(refused(
codes::NO_FACTOR_HELD,
"you hold no step-up factor here to authorize this from; ask a community \
administrator for an approver invite (an administrator never invites themselves)",
));
}
let terms = enrolment_terms(raw_payload);
let approver_did = payload.approver_did.to_string();
let replaces = payload.replaces.as_ref().map(|r| r.to_string());
let Some(mark) = bound_step_up::peek_mark(state, subject, ENROLL_TYPE, &terms).await? else {
let reason = match &replaces {
Some(old) => format!("Replace your step-up approver {old} with {approver_did}"),
None => format!("Add {approver_did} as a step-up approver"),
};
let request =
bound_step_up::request_step_up(state, subject, ENROLL_TYPE, &terms, &reason).await?;
return Err(ApproverTaskError::StepUp(Box::new(request)));
};
let statement_invalid = || {
refused(
codes::STATEMENT_INVALID,
"the new approver's enrolment statement is missing, or not valid for the step-up \
that authorized this enrolment",
)
};
let Some(statement) = raw_payload.get("statement") else {
return Err(statement_invalid());
};
if mark.challenge.is_empty() {
return Err(statement_invalid());
}
let digest = terms_digest(subject, &mark.challenge, &terms)?;
let expected = ExpectedStatement {
purpose: attest::PayloadPurpose::Enrol,
subject,
challenge: &mark.challenge,
bound_to: &digest,
not_before: epoch_to_utc(mark.created_at),
not_after: epoch_to_utc(mark.expires_at),
approver: ExpectedApprover::Enrolling(&approver_did),
};
match approver::verify_statement(state, statement, &expected).await {
Ok(_) => {}
Err(StatementError::Internal(e)) => return Err(e.into()),
Err(_) => return Err(statement_invalid()),
}
if let Err(why) = approver::check_distinct(state, subject, &approver_did).await? {
warn!(%subject, approver = %approver_did, why, "approver refused: not distinct");
return Err(refused(
codes::APPROVER_NOT_DISTINCT,
"that approver is a key you already sign with, or holds standing here",
));
}
let _guard = approver::lock().await;
let ks = &state.step_up_approvers_ks;
approver::check_bindable(ks, &approver_did, subject, replaces.as_deref())
.await
.map_err(|e| {
bind_refusal(
e,
codes::APPROVER_ALREADY_BOUND,
codes::TOO_MANY_APPROVERS,
Some(codes::REPLACE_NOT_FOUND),
)
})?;
if !bound_step_up::spend_mark(state, subject, ENROLL_TYPE, &terms).await? {
let request = bound_step_up::request_step_up(
state,
subject,
ENROLL_TYPE,
&terms,
&format!("Add {approver_did} as a step-up approver"),
)
.await?;
return Err(ApproverTaskError::StepUp(Box::new(request)));
}
let anchor = format!("{}:{}", mark.evidence.kind, mark.evidence.credential_id);
let record = ApproverRecord {
approver_did: approver_did.clone(),
subject: subject.to_string(),
label: payload.label.as_ref().map(|l| l.to_string()),
enrolled_at: Utc::now(),
enrolled_via: EnrolledVia::SelfService,
anchor: anchor.clone(),
last_used_at: None,
revoked_at: None,
revoked_by: None,
};
approver::write_binding(ks, &record, replaces.as_deref(), subject)
.await
.map_err(|e| {
bind_refusal(
e,
codes::APPROVER_ALREADY_BOUND,
codes::TOO_MANY_APPROVERS,
Some(codes::REPLACE_NOT_FOUND),
)
})?;
drop(_guard);
audit(
state,
subject,
StepUpApproverData {
approver_did: Some(approver_did.clone()),
enrolled_via: Some(EnrolledVia::SelfService.as_str().into()),
anchor: Some(anchor),
replaced: replaces.clone(),
..stage("enrolled", subject)
},
)
.await?;
after_binding(state, subject).await?;
info!(%subject, approver = %approver_did, replaced = ?replaces, "step-up approver enrolled (self-service)");
serde_json::from_value(json!({ "approver": record.to_wire() }))
.map_err(|e| AppError::Internal(format!("enroll response: {e}")).into())
}
pub async fn list(state: &AppState, subject: &str) -> Result<list::Response> {
let approvers: Vec<Value> = approver::stored_live(&state.step_up_approvers_ks, subject)
.await?
.iter()
.map(ApproverRecord::to_wire)
.collect();
serde_json::from_value(json!({ "approvers": approvers }))
.map_err(|e| AppError::Internal(format!("list response: {e}")).into())
}
pub async fn check_revoke(
state: &AppState,
subject: &str,
approver_did: &str,
) -> Result<Option<DateTime<Utc>>> {
match approver::get(&state.step_up_approvers_ks, approver_did).await? {
Some(r) if r.subject == subject => Ok(r.revoked_at),
_ => Err(refused(
revoke::error_codes::NOT_FOUND,
"no binding of this approver that you may revoke",
)),
}
}
pub async fn revoke(
state: &AppState,
caller: &str,
subject: &str,
payload: &revoke::Payload,
) -> Result<revoke::Response> {
let ks = &state.step_up_approvers_ks;
let approver_did = payload.approver_did.to_string();
let guard = approver::lock().await;
let before = approver::get(ks, &approver_did).await?;
let record = match before {
Some(r) if r.subject == subject => r,
_ => {
return Err(refused(
revoke::error_codes::NOT_FOUND,
"no binding of this approver that you may revoke",
));
}
};
let (revoked, revoked_at) = if let Some(at) = record.revoked_at {
(record, at)
} else {
let r = approver::tombstone(ks, subject, &approver_did, caller)
.await?
.ok_or_else(|| AppError::Internal("binding vanished under the lock".into()))?;
let at = approver::get(ks, &approver_did)
.await?
.and_then(|t| t.revoked_at)
.unwrap_or_else(Utc::now);
audit(
state,
caller,
StepUpApproverData {
approver_did: Some(approver_did.clone()),
reason: payload.reason.as_ref().map(|r| r.to_string()),
..stage("revoked", subject)
},
)
.await?;
info!(%caller, %subject, approver = %approver_did, "step-up approver revoked");
(r, at)
};
let remaining = approver::stored_live(ks, subject).await?.len();
drop(guard);
serde_json::from_value(json!({
"revoked": revoked.to_wire(),
"revokedAt": revoked_at,
"remainingApprovers": remaining,
}))
.map_err(|e| AppError::Internal(format!("revoke response: {e}")).into())
}
fn install_invalid_token_start() -> ApproverTaskError {
refused(
claim_start::error_codes::INVALID_TOKEN,
"this install token and claim code do not open a claim",
)
}
pub async fn claim_start_v0_3(
state: &AppState,
payload: &claim_start::Payload,
) -> Result<claim_start::Response> {
let signer = state
.install_signer
.as_ref()
.ok_or_else(|| AppError::ServiceError {
status: axum::http::StatusCode::SERVICE_UNAVAILABLE,
message: "install signer not configured (run setup first)".into(),
})?;
let claims = crate::install::parse_install_token(signer, payload.token.as_str())
.map_err(|_| install_invalid_token_start())?;
let jti = Uuid::parse_str(&claims.jti).map_err(|_| install_invalid_token_start())?;
let store = &state.install_store;
let exp = match store.get_token(&jti).await? {
Some(crate::install::InstallTokenState::Issued { exp, .. }) if Utc::now() < exp => exp,
_ => {
let _ = tokio::task::spawn_blocking(|| claim_secret::hash("timing")).await;
return Err(install_invalid_token_start());
}
};
let Some(hash) = store.peek_secret_hash(&jti).await? else {
let _ = tokio::task::spawn_blocking(|| claim_secret::hash("timing")).await;
return Err(install_invalid_token_start());
};
let supplied = normalise_code(payload.claim_code.as_str());
let ok = tokio::task::spawn_blocking(move || claim_secret::verify(&supplied, &hash))
.await
.map_err(|e| AppError::Internal(format!("claim-code verify task failed: {e}")))??;
if !ok {
let wrong = count_wrong_install_code(state, &jti).await?;
if wrong >= MAX_WRONG_CODES {
store.delete_token(&jti).await?;
state
.step_up_approvers_ks
.remove(install_wrong_key(&jti))
.await?;
warn!(
%jti,
security_alert = true,
"install token voided after {MAX_WRONG_CODES} wrong claim codes"
);
}
return Err(install_invalid_token_start());
}
if !claims.admin_did.starts_with("did:") {
return Err(refused(
claim_start::error_codes::TOKEN_NAMES_NO_DID,
"this install token names no administrator DID; claim it with \
vtc/install/claim/start 0.2",
));
}
let audience = vtc_did(state).await?;
let claim_id = format!("clm_{}", random_hex(16));
let challenge = random_b64(32);
let now = now_epoch();
let expires_at = now
.saturating_add(CEREMONY_TTL_SECS)
.min(exp.timestamp().max(0) as u64);
state
.step_up_approvers_ks
.insert(
claim_key(&claim_id),
&InstallClaim {
jti,
admin_did: claims.admin_did.clone(),
challenge: challenge.clone(),
created_at: now,
expires_at,
},
)
.await?;
info!(%jti, admin_did = %claims.admin_did, "install claim 0.3 opened");
serde_json::from_value(json!({
"claimId": claim_id,
"adminDid": claims.admin_did,
"challenge": challenge,
"audience": audience,
"expiresAt": epoch_to_utc(expires_at),
}))
.map_err(|e| AppError::Internal(format!("claim/start 0.3 response: {e}")).into())
}
pub(crate) async fn claim_admin_did(
state: &AppState,
claim_id: &str,
) -> Result<Option<String>, AppError> {
Ok(state
.step_up_approvers_ks
.get::<InstallClaim>(claim_key(claim_id))
.await?
.filter(|c| now_epoch() < c.expires_at)
.map(|c| c.admin_did))
}
pub async fn claim_finish_v0_3(
state: &AppState,
signer: &str,
payload: &claim_finish::Payload,
statement: &Value,
) -> Result<crate::routes::install::ClaimFinishResponse> {
use claim_finish::error_codes as codes;
let ks = &state.step_up_approvers_ks;
let claim_id = payload.claim_id.as_str();
let approver_did = payload.approver_did.to_string();
let install_signer = state
.install_signer
.as_ref()
.ok_or_else(|| AppError::ServiceError {
status: axum::http::StatusCode::SERVICE_UNAVAILABLE,
message: "install signer not configured (run setup first)".into(),
})?;
let _guard = approver::lock().await;
let Some(claim) = ks
.get::<InstallClaim>(claim_key(claim_id))
.await?
.filter(|c| now_epoch() < c.expires_at)
else {
return Err(refused(
codes::REGISTRATION_MISMATCH,
"this claimId names no open claim, or it has expired",
));
};
match state.install_store.get_token(&claim.jti).await? {
Some(crate::install::InstallTokenState::Issued { exp, .. }) if Utc::now() < exp => {}
_ => {
ks.remove(claim_key(claim_id)).await?;
return Err(refused(
codes::INVALID_TOKEN,
"the install token this claim was opened under has expired or been consumed",
));
}
}
if signer != claim.admin_did || is_delegated_key(state, signer).await? {
return Err(refused(
codes::SUBJECT_MISMATCH,
"the claim must be signed by the DID the install token names, with a key of its own \
document",
));
}
let expected = ExpectedStatement {
purpose: attest::PayloadPurpose::Enrol,
subject: &claim.admin_did,
challenge: &claim.challenge,
bound_to: claim_id,
not_before: epoch_to_utc(claim.created_at),
not_after: epoch_to_utc(claim.expires_at),
approver: ExpectedApprover::Enrolling(&approver_did),
};
match approver::verify_statement(state, statement, &expected).await {
Ok(_) => {}
Err(StatementError::Internal(e)) => return Err(e.into()),
Err(_) => {
return Err(refused(
codes::STATEMENT_INVALID,
"the approver's enrolment statement is not valid for this claim",
));
}
}
let distinct = approver::check_distinct(state, &claim.admin_did, &approver_did).await?;
let bindable = approver::check_bindable(ks, &approver_did, &claim.admin_did, None).await;
if distinct.is_err() || bindable.is_err() {
return Err(refused(
codes::APPROVER_NOT_DISTINCT,
"that approver is the founder's DID, a key of its document, or already bound; a \
step-up factor must be a key the founder does not already hold for signing",
));
}
state
.install_store
.finish_claim(&claim.jti)
.await
.map_err(|e| refused(codes::INVALID_TOKEN, e.to_string()))?;
ks.remove(claim_key(claim_id)).await?;
let session_expires =
now_epoch().saturating_add(crate::install::INSTALL_SESSION_DEFAULT_TTL_SECS);
ks.insert(
pending_key(&claim.jti.to_string()),
&PendingInstallBinding {
admin_did: claim.admin_did.clone(),
approver_did: approver_did.clone(),
label: payload.label.as_ref().map(|l| l.to_string()),
expires_at: session_expires,
},
)
.await?;
drop(_guard);
audit(
state,
&claim.admin_did,
StepUpApproverData {
approver_did: Some(approver_did.clone()),
enrolled_via: Some(EnrolledVia::Install.as_str().into()),
anchor: Some(claim.jti.to_string()),
expires_at: Some(epoch_to_utc(session_expires)),
..stage("claimed", &claim.admin_did)
},
)
.await?;
info!(jti = %claim.jti, admin_did = %claim.admin_did, approver = %approver_did, "install claim 0.3 completed");
Ok(crate::routes::install::issue_setup_session(
state,
install_signer,
claim.admin_did,
&claim.jti,
)
.await?)
}
pub(crate) async fn take_pending_install(
state: &AppState,
install_jti: &str,
admin_did: &str,
) -> Result<Option<PendingInstallBinding>, AppError> {
let ks = &state.step_up_approvers_ks;
let Some(raw) = ks.take_raw(pending_key(install_jti)).await? else {
return Ok(None);
};
let Ok(p) = serde_json::from_slice::<PendingInstallBinding>(&raw) else {
return Ok(None);
};
if p.admin_did != admin_did {
return Ok(None);
}
if now_epoch() >= p.expires_at {
audit(
state,
admin_did,
StepUpApproverData {
approver_did: Some(p.approver_did.clone()),
anchor: Some(install_jti.into()),
..stage("discarded", admin_did)
},
)
.await?;
return Ok(None);
}
Ok(Some(p))
}
pub(crate) async fn bind_installed(
state: &AppState,
install_jti: &str,
pending: &PendingInstallBinding,
) -> Result<(), AppError> {
let record = ApproverRecord {
approver_did: pending.approver_did.clone(),
subject: pending.admin_did.clone(),
label: pending.label.clone(),
enrolled_at: Utc::now(),
enrolled_via: EnrolledVia::Install,
anchor: install_jti.to_string(),
last_used_at: None,
revoked_at: None,
revoked_by: None,
};
{
let _guard = approver::lock().await;
approver::write_binding(
&state.step_up_approvers_ks,
&record,
None,
&pending.admin_did,
)
.await
.map_err(|e| match e {
BindError::Internal(e) => e,
other => AppError::Conflict(format!(
"the approver claimed at install cannot be bound: {other:?}"
)),
})?;
}
audit(
state,
&pending.admin_did,
StepUpApproverData {
approver_did: Some(pending.approver_did.clone()),
enrolled_via: Some(EnrolledVia::Install.as_str().into()),
anchor: Some(install_jti.to_string()),
..stage("enrolled", &pending.admin_did)
},
)
.await?;
Ok(())
}
pub async fn sweep_expired(ks: &KeyspaceHandle, now: DateTime<Utc>) -> Result<usize, AppError> {
#[derive(Deserialize)]
struct Expiring {
expires_at: u64,
}
let t = now.timestamp().max(0) as u64;
let mut removed = 0;
for prefix in [&b"invite:"[..], b"redeem:", b"claim:", b"pending:"] {
for (key, value) in ks.prefix_iter_raw(prefix.to_vec()).await? {
let lapsed =
serde_json::from_slice::<Expiring>(&value).map_or(true, |e| t >= e.expires_at);
if lapsed {
ks.remove(key).await?;
removed += 1;
}
}
}
removed += approver::sweep_spent(ks, now).await?;
Ok(removed)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_invite_key_is_a_hash_and_never_the_token() {
let key = invite_key("sua_secret");
assert!(key.starts_with("invite:"));
assert!(!key.contains("sua_secret"));
}
#[test]
fn the_terms_are_the_payload_without_its_statement() {
let with =
json!({ "approverDid": "did:key:z6MkA", "label": "x", "statement": { "id": "1" } });
let without = json!({ "approverDid": "did:key:z6MkA", "label": "x" });
assert_eq!(enrolment_terms(&with), without);
assert_eq!(
terms_digest(
"did:key:z6MkS",
"challenge-challenge",
&enrolment_terms(&with)
)
.unwrap(),
terms_digest("did:key:z6MkS", "challenge-challenge", &without).unwrap()
);
}
#[test]
fn the_terms_digest_binds_subject_challenge_and_every_term() {
let t = json!({ "approverDid": "did:key:z6MkA" });
let d = terms_digest("did:key:z6MkS", "c1c1c1c1c1c1c1c1", &t).unwrap();
assert!(d.starts_with('z'));
let (_, bytes) = multibase::decode(&d).unwrap();
assert_eq!(&bytes[..2], &[0x12, 0x20]);
assert_ne!(
d,
terms_digest("did:key:z6MkT", "c1c1c1c1c1c1c1c1", &t).unwrap()
);
assert_ne!(
d,
terms_digest("did:key:z6MkS", "c2c2c2c2c2c2c2c2", &t).unwrap()
);
let replacing = json!({ "approverDid": "did:key:z6MkA", "replaces": "did:key:z6MkB" });
assert_ne!(
d,
terms_digest("did:key:z6MkS", "c1c1c1c1c1c1c1c1", &replacing).unwrap(),
"a rotation is a different enrolment"
);
}
#[test]
fn the_terms_digest_matches_its_pinned_vector() {
let d = terms_digest(
"did:webvh:QmAliceScid4:wallet.example:alice",
"Um90YXRlQXBwcm92ZXJOb25jZTk4NzY1NA",
&json!({
"approverDid": "did:key:z6MktwupdmLXVVqTzCw4i46r4uGyosGXRnR3XjN4Zq7oMMsw",
"label": "Browser plugin — new laptop",
}),
)
.unwrap();
let jcs = "{\"challenge\":\"Um90YXRlQXBwcm92ZXJOb25jZTk4NzY1NA\",\"subject\":\"did:webvh:QmAliceScid4:wallet.example:alice\",\"terms\":{\"approverDid\":\"did:key:z6MktwupdmLXVVqTzCw4i46r4uGyosGXRnR3XjN4Zq7oMMsw\",\"label\":\"Browser plugin — new laptop\"},\"type\":\"https://trusttasks.org/spec/auth/step-up/approver/enroll/0.1\"}";
let mut mh = vec![0x12, 0x20];
mh.extend_from_slice(&Sha256::digest(jcs.as_bytes()));
assert_eq!(d, multibase::encode(multibase::Base::Base58Btc, mh));
}
#[test]
fn the_terms_digest_matches_the_consoles_pinned_vector() {
let d = terms_digest(
"did:webvh:QmAliceScid4:wallet.example:alice",
"Um90YXRlQXBwcm92ZXJOb25jZTk4NzY1NA",
&json!({
"approverDid": "did:key:z6MktwupdmLXVVqTzCw4i46r4uGyosGXRnR3XjN4Zq7oMMsw",
"label": "Browser plugin — new laptop",
"replaces": "did:key:z6MkpTHR8VNsBxYAAWHut2Geadd9jSwuBV8xRoAnwWsdvktH",
}),
)
.unwrap();
assert_eq!(d, "zQmf4btNkvextciSMXxYrSktWXTaMK7di3hzUV187tZHoHZ");
}
#[test]
fn a_claim_code_is_read_as_people_type_it() {
assert_eq!(normalise_code(" abcd-efgh "), "ABCDEFGH");
}
}