use std::sync::Arc;
use affinidi_sd_jwt::error::SdJwtError;
use affinidi_secrets_resolver::secrets::Secret;
use base64::Engine;
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
use ed25519_dalek::{Signature, Signer, SigningKey};
use serde_json::Value;
use vta_sdk::keys::{KeyOrigin, KeyRecord, KeyStatus, KeyType};
use zeroize::Zeroizing;
use crate::auth::AuthClaims;
use crate::keys::seed_store::SeedStore;
use crate::store::KeyspaceHandle;
use vti_common::error::AppError;
#[derive(Debug)]
pub struct HolderSdJwtSigner {
key: SigningKey,
kid: String,
}
impl affinidi_sd_jwt::signer::JwtSigner for HolderSdJwtSigner {
fn algorithm(&self) -> &str {
"EdDSA"
}
fn key_id(&self) -> Option<&str> {
Some(&self.kid)
}
fn sign_jwt(&self, header: &Value, payload: &Value) -> Result<String, SdJwtError> {
let h = URL_SAFE_NO_PAD.encode(
serde_json::to_vec(header).map_err(|e| SdJwtError::Verification(e.to_string()))?,
);
let p = URL_SAFE_NO_PAD.encode(
serde_json::to_vec(payload).map_err(|e| SdJwtError::Verification(e.to_string()))?,
);
let input = format!("{h}.{p}");
let sig: Signature = self.key.sign(input.as_bytes());
Ok(format!(
"{input}.{}",
URL_SAFE_NO_PAD.encode(sig.to_bytes())
))
}
}
#[derive(Debug)]
pub struct HolderKeys {
pub signer: HolderSdJwtSigner,
pub consent_secret: Secret,
}
pub async fn resolve_holder_keys(
keys_ks: &KeyspaceHandle,
contexts_ks: &KeyspaceHandle,
seed_store: &Arc<dyn SeedStore>,
audit: &vta_audit::SharedAuditSink,
auth: &AuthClaims,
subject_did: &str,
) -> Result<HolderKeys, AppError> {
let multibase = subject_did.strip_prefix("did:key:").ok_or_else(|| {
AppError::Validation(format!("holder subject `{subject_did}` is not a did:key"))
})?;
let key_id = format!("{subject_did}#{multibase}");
let record: KeyRecord = keys_ks
.get(crate::keys::store_key(&key_id))
.await?
.ok_or_else(|| {
AppError::NotFound(format!(
"holder key for `{subject_did}` is not managed by this VTA"
))
})?;
if record.key_type != KeyType::Ed25519 {
return Err(AppError::Validation(format!(
"holder key `{key_id}` is not an Ed25519 key"
)));
}
if record.status != KeyStatus::Active {
return Err(AppError::Validation(format!(
"holder key `{key_id}` is not active"
)));
}
if record.origin != KeyOrigin::Derived {
return Err(AppError::Validation(
"imported holder keys are not supported for presentation yet".into(),
));
}
match &record.context_id {
Some(ctx) => auth.require_context(ctx)?,
None => auth.require_super_admin()?,
}
let key = crate::operations::key_custody::derive_record_key(
contexts_ks,
keys_ks,
&**seed_store,
audit,
&auth.did,
&record,
"holder-keys",
)
.await?;
let derived_bytes = key.ed25519_signing_key_bytes()?;
let signing_key = ed25519_dalek::SigningKey::from_bytes(&derived_bytes);
let signer = HolderSdJwtSigner {
key: signing_key.clone(),
kid: key_id.clone(),
};
let mut consent_secret = Secret::generate_ed25519(Some(&key_id), Some(signing_key.as_bytes()));
consent_secret.id = key_id;
Ok(HolderKeys {
signer,
consent_secret,
})
}
#[cfg(test)]
mod tests {
use super::*;
use crate::acl::Role;
use affinidi_sd_jwt::signer::JwtSigner;
use chrono::Utc;
use vti_common::config::StoreConfig;
use vti_common::slip10::{DerivationPath, ExtendedSigningKey};
use vti_common::store::Store;
fn admin_of(ctx: &str) -> AuthClaims {
AuthClaims {
role: Role::Admin,
allowed_contexts: vec![ctx.to_string()],
..Default::default()
}
}
fn super_admin() -> AuthClaims {
AuthClaims {
role: Role::Admin,
allowed_contexts: Vec::new(),
..Default::default()
}
}
async fn setup_with_base(context: Option<&str>, context_base: &str) -> Fixture {
let dir = tempfile::tempdir().unwrap();
let store = Store::open(&StoreConfig {
data_dir: dir.path().to_path_buf(),
})
.unwrap();
let keys_ks = store.keyspace(crate::keyspaces::KEYS).unwrap();
let contexts_ks = store.keyspace(crate::keyspaces::CONTEXTS).unwrap();
let audit =
vta_audit::shared_keyspace_sink(store.keyspace(crate::keyspaces::AUDIT).unwrap());
if let Some(ctx) = context {
crate::contexts::store_context(
&contexts_ks,
&crate::contexts::ContextRecord {
id: ctx.into(),
name: ctx.into(),
did: None,
description: None,
parent: None,
base_path: context_base.into(),
index: 0,
created_at: Utc::now(),
updated_at: Utc::now(),
context_policy: None,
},
)
.await
.unwrap();
}
let seed = vec![42u8; 64];
let seed_store: Arc<dyn SeedStore> =
Arc::new(crate::test_support::TestSeedStore(seed.clone()));
let path = "m/26'/2'/0'/0'";
let bip32 = ExtendedSigningKey::from_seed(&seed).unwrap();
let derived = bip32
.derive(&path.parse::<DerivationPath>().unwrap())
.unwrap();
let subject_did = affinidi_crypto::did_key::ed25519_pub_to_did_key(
derived.signing_key.verifying_key().as_bytes(),
);
let multibase = subject_did.strip_prefix("did:key:").unwrap();
let key_id = format!("{subject_did}#{multibase}");
let record = KeyRecord {
exportable: None,
key_id: key_id.clone(),
derivation_path: path.to_string(),
key_type: KeyType::Ed25519,
status: KeyStatus::Active,
public_key: multibase.to_string(),
label: None,
context_id: context.map(str::to_string),
seed_id: None,
origin: KeyOrigin::Derived,
created_at: Utc::now(),
updated_at: Utc::now(),
};
keys_ks
.insert(crate::keys::store_key(&key_id), &record)
.await
.unwrap();
Fixture {
_dir: dir,
_store: store,
keys_ks,
contexts_ks,
audit,
seed_store,
subject_did,
}
}
struct Fixture {
_dir: tempfile::TempDir,
_store: Store,
keys_ks: KeyspaceHandle,
contexts_ks: KeyspaceHandle,
audit: vta_audit::SharedAuditSink,
seed_store: Arc<dyn SeedStore>,
subject_did: String,
}
async fn setup(context: Option<&str>) -> Fixture {
setup_with_base(context, "m/26'/2'/0'").await
}
async fn resolve(
f: &Fixture,
auth: &AuthClaims,
subject: &str,
) -> Result<HolderKeys, AppError> {
resolve_holder_keys(
&f.keys_ks,
&f.contexts_ks,
&f.seed_store,
&f.audit,
auth,
subject,
)
.await
}
#[tokio::test]
async fn resolves_within_an_authorised_context() {
let f = setup(Some("acme")).await;
let subject_did = f.subject_did.clone();
let keys = resolve(&f, &admin_of("acme"), &subject_did)
.await
.expect("resolve");
let multibase = subject_did.strip_prefix("did:key:").unwrap();
let key_id = format!("{subject_did}#{multibase}");
assert_eq!(keys.signer.key_id(), Some(key_id.as_str()));
assert_eq!(keys.consent_secret.id, key_id);
}
#[tokio::test]
async fn refuses_a_key_outside_the_callers_context() {
let f = setup(Some("acme")).await;
let Err(err) = resolve(&f, &admin_of("other"), &f.subject_did).await else {
panic!("expected a refusal");
};
assert!(matches!(err, AppError::Forbidden(_)), "{err:?}");
}
#[tokio::test]
async fn parent_admin_resolves_a_descendant_context_key() {
let f = setup(Some("acme/eng")).await;
assert!(resolve(&f, &admin_of("acme"), &f.subject_did).await.is_ok());
}
#[tokio::test]
async fn unknown_subject_is_not_found() {
let f = setup(Some("acme")).await;
let Err(err) = resolve(&f, &super_admin(), "did:key:zUnknownHolder").await else {
panic!("expected a refusal");
};
assert!(matches!(err, AppError::NotFound(_)), "{err:?}");
}
#[tokio::test]
async fn a_record_outside_its_contexts_base_cannot_sign() {
let f = setup_with_base(Some("acme"), "m/26'/2'/5'").await;
let Err(err) = resolve(&f, &admin_of("acme"), &f.subject_did).await else {
panic!("expected a refusal");
};
assert!(matches!(err, AppError::Forbidden(_)), "{err:?}");
}
}
#[derive(Debug)]
pub struct MdocHolderKeys {
pub device_did: String,
pub consent_secret: Secret,
pub device_private: Zeroizing<Vec<u8>>,
}
pub async fn resolve_mdoc_device_keys(
keys_ks: &KeyspaceHandle,
contexts_ks: &KeyspaceHandle,
seed_store: &Arc<dyn SeedStore>,
audit: &vta_audit::SharedAuditSink,
auth: &AuthClaims,
key_id: &str,
) -> Result<MdocHolderKeys, AppError> {
let record: KeyRecord = keys_ks
.get(crate::keys::store_key(key_id))
.await?
.ok_or_else(|| {
AppError::NotFound(format!(
"mdoc device key `{key_id}` is not managed by this VTA"
))
})?;
if record.key_type != KeyType::P256 {
return Err(AppError::Validation(format!(
"mdoc device key `{key_id}` is {:?}, but ISO 18013-5 device keys are P-256",
record.key_type
)));
}
if record.status != KeyStatus::Active {
return Err(AppError::Validation(format!(
"mdoc device key `{key_id}` is not active"
)));
}
if record.origin != KeyOrigin::Derived {
return Err(AppError::Validation(
"imported mdoc device keys are not supported for presentation yet".into(),
));
}
match &record.context_id {
Some(ctx) => auth.require_context(ctx)?,
None => auth.require_super_admin()?,
}
let p256_secret = crate::operations::key_custody::derive_record_key(
contexts_ks,
keys_ks,
&**seed_store,
audit,
&auth.did,
&record,
"holder-keys",
)
.await?
.p256_secret()?;
let device_private = Zeroizing::new(p256_secret.secret_key.to_bytes().to_vec());
let device_did = format!("did:key:{}", record.public_key);
let vm = format!("{device_did}#{}", record.public_key);
let consent_secret = Secret::from_multibase(
&vta_keys::encode_private_multibase(&KeyType::P256, &device_private),
Some(&vm),
)
.map_err(|e| AppError::Internal(format!("build P-256 consent secret: {e}")))?;
Ok(MdocHolderKeys {
device_did,
consent_secret,
device_private,
})
}