use std::fmt;
use p256::elliptic_curve::sec1::ToSec1Point;
use affinidi_tdk::secrets_resolver::secrets::Secret;
use vti_common::error::{AppError, key_derivation_error};
use vti_common::slip10::{ChildIndex, DerivationPath, ExtendedSigningKey};
use vti_common::store::KeyspaceHandle;
use zeroize::Zeroizing;
use crate::derivation::{Bip32Extension, P256Secret};
use crate::seed_store::SeedStore;
use crate::seeds::load_seed_bytes;
use crate::{KeyOrigin, KeyRecord, KeyType, encode_private_multibase, encode_public_multibase};
pub const DELEGATED_IDENTITY_ROOT: &str = "m/26'/9'";
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum CustodyViolation {
UnparseablePath { path: String, reason: String },
OutsideDelegatedRoot { path: String },
InsideDelegatedRoot { path: String },
ForeignPath {
path: String,
requested_context: Option<String>,
owning_context: Option<String>,
},
RecordOutsideContext {
key_id: String,
path: String,
context_id: String,
context_base: String,
},
RecordContextMissing { key_id: String, context_id: String },
NotDerived { key_id: String },
KeyOutsideScope {
key_id: String,
record_context: Option<String>,
scope_context: String,
},
}
impl fmt::Display for CustodyViolation {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::UnparseablePath { path, reason } => {
write!(
f,
"derivation path `{path}` is not a valid BIP-32 path: {reason}"
)
}
Self::OutsideDelegatedRoot { path } => write!(
f,
"derive-and-sign is confined to the delegated-identity subtree \
{DELEGATED_IDENTITY_ROOT} (all indexes hardened); `{path}` is outside it"
),
Self::InsideDelegatedRoot { path } => write!(
f,
"`{path}` is inside the delegated-identity subtree \
{DELEGATED_IDENTITY_ROOT}, which is sign-only: no key record may be \
created there. Use keys/derive-and-sign instead"
),
Self::ForeignPath {
path,
requested_context,
owning_context,
} => write!(
f,
"derivation path `{path}` belongs to {} and cannot be recorded under {}. \
A key must be derived from its own context's base (VTI-KEY-030, \
VTI-KEY-032); omit the path to have one allocated",
describe_context(owning_context.as_deref()),
describe_context(requested_context.as_deref()),
),
Self::RecordOutsideContext {
key_id,
path,
context_id,
context_base,
} => write!(
f,
"key `{key_id}` is recorded under context `{context_id}` (base \
{context_base}) but its derivation path `{path}` is outside that base; \
refusing to derive it (VTI-KEY-032)"
),
Self::RecordContextMissing { key_id, context_id } => write!(
f,
"key `{key_id}` names context `{context_id}`, which does not exist; \
refusing to derive it"
),
Self::NotDerived { key_id } => {
write!(f, "key `{key_id}` is not a derived key")
}
Self::KeyOutsideScope {
key_id,
record_context,
scope_context,
} => write!(
f,
"key `{key_id}` belongs to {} and cannot be used on behalf of context \
`{scope_context}`: a key referenced from a context-scoped resource must \
be in that context or a descendant of it (VTI-CTX-002)",
describe_context(record_context.as_deref()),
),
}
}
}
fn describe_context(ctx: Option<&str>) -> String {
match ctx {
Some(c) => format!("context `{c}`"),
None => "no context".to_string(),
}
}
impl std::error::Error for CustodyViolation {}
impl From<CustodyViolation> for AppError {
fn from(v: CustodyViolation) -> Self {
AppError::Forbidden(v.to_string())
}
}
pub fn parse_path(path: &str) -> Result<DerivationPath, CustodyViolation> {
path.parse::<DerivationPath>()
.map_err(|e| CustodyViolation::UnparseablePath {
path: path.to_string(),
reason: e.to_string(),
})
}
pub fn is_strictly_within(base: &DerivationPath, path: &DerivationPath) -> bool {
let (b, p) = (base.path(), path.path());
p.len() > b.len() && p[..b.len()] == *b
}
pub fn owning_context<'a, I>(path: &DerivationPath, contexts: I) -> Option<&'a str>
where
I: IntoIterator<Item = (&'a str, &'a str)>,
{
let mut best: Option<(&'a str, usize)> = None;
for (id, base) in contexts {
let Ok(base) = base.parse::<DerivationPath>() else {
continue;
};
if is_strictly_within(&base, path) && best.is_none_or(|(_, depth)| base.len() > depth) {
best = Some((id, base.len()));
}
}
best.map(|(id, _)| id)
}
pub fn check_explicit_key_path<'a, I>(
path: &str,
context_id: Option<&str>,
contexts: I,
) -> Result<DerivationPath, CustodyViolation>
where
I: IntoIterator<Item = (&'a str, &'a str)>,
{
let parsed = parse_path(path)?;
let delegated = parse_path(DELEGATED_IDENTITY_ROOT).expect("constant path parses");
if parsed == delegated || is_strictly_within(&delegated, &parsed) {
return Err(CustodyViolation::InsideDelegatedRoot {
path: path.to_string(),
});
}
let owner = owning_context(&parsed, contexts);
if owner != context_id {
return Err(CustodyViolation::ForeignPath {
path: path.to_string(),
requested_context: context_id.map(str::to_string),
owning_context: owner.map(str::to_string),
});
}
Ok(parsed)
}
pub fn check_delegated_identity_path(path: &str) -> Result<DerivationPath, CustodyViolation> {
let parsed = parse_path(path)?;
let root = parse_path(DELEGATED_IDENTITY_ROOT).expect("constant path parses");
let hardened = parsed.path().iter().all(|i| i.is_hardened());
if !is_strictly_within(&root, &parsed) || !hardened {
return Err(CustodyViolation::OutsideDelegatedRoot {
path: path.to_string(),
});
}
Ok(parsed)
}
pub fn check_key_in_scope(record: &KeyRecord, scope_context: &str) -> Result<(), CustodyViolation> {
match record.context_id.as_deref() {
Some(key_ctx) if vta_sdk::context_path::is_ancestor_or_self(scope_context, key_ctx) => {
Ok(())
}
other => Err(CustodyViolation::KeyOutsideScope {
key_id: record.key_id.clone(),
record_context: other.map(str::to_string),
scope_context: scope_context.to_string(),
}),
}
}
#[derive(Debug)]
pub struct AuthorizedRecordDerivation {
path: DerivationPath,
path_str: String,
key_type: KeyType,
seed_id: Option<u32>,
}
pub fn authorize_record_derivation(
record: &KeyRecord,
context_base: Option<&str>,
) -> Result<AuthorizedRecordDerivation, CustodyViolation> {
if record.origin != KeyOrigin::Derived {
return Err(CustodyViolation::NotDerived {
key_id: record.key_id.clone(),
});
}
let path = parse_path(&record.derivation_path)?;
if let Some(ctx) = record.context_id.as_deref() {
let Some(base_str) = context_base else {
return Err(CustodyViolation::RecordContextMissing {
key_id: record.key_id.clone(),
context_id: ctx.to_string(),
});
};
let within = parse_path(base_str)
.map(|base| is_strictly_within(&base, &path))
.unwrap_or(false);
if !within {
return Err(CustodyViolation::RecordOutsideContext {
key_id: record.key_id.clone(),
path: record.derivation_path.clone(),
context_id: ctx.to_string(),
context_base: base_str.to_string(),
});
}
}
Ok(AuthorizedRecordDerivation {
path,
path_str: record.derivation_path.clone(),
key_type: record.key_type.clone(),
seed_id: record.seed_id,
})
}
impl AuthorizedRecordDerivation {
pub async fn load(
self,
keys_ks: &KeyspaceHandle,
seed_store: &dyn SeedStore,
) -> Result<RecordKey, AppError> {
let seed = load_seed_bytes(keys_ks, seed_store, self.seed_id)
.await
.map_err(|e| AppError::Internal(format!("{e}")))?;
let root = ExtendedSigningKey::from_seed(&seed)
.map_err(|e| key_derivation_error(format!("failed to create BIP-32 root key: {e}")))?;
Ok(RecordKey {
root,
path: self.path,
path_str: self.path_str,
key_type: self.key_type,
})
}
}
pub struct RecordKey {
root: ExtendedSigningKey,
path: DerivationPath,
path_str: String,
key_type: KeyType,
}
impl fmt::Debug for RecordKey {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("RecordKey")
.field("path", &self.path_str)
.field("key_type", &self.key_type)
.finish_non_exhaustive()
}
}
impl RecordKey {
pub fn key_type(&self) -> &KeyType {
&self.key_type
}
pub fn ed25519_signing_key_bytes(&self) -> Result<Zeroizing<[u8; 32]>, AppError> {
let node = self
.root
.derive(&self.path)
.map_err(|e| key_derivation_error(format!("derivation failed: {e}")))?;
Ok(Zeroizing::new(*node.signing_key.as_bytes()))
}
pub fn ed25519_secret(&self) -> Result<Secret, AppError> {
self.root.derive_ed25519(&self.path_str)
}
pub fn x25519_secret(&self) -> Result<Secret, AppError> {
self.root.derive_x25519(&self.path_str)
}
pub fn p256_secret(&self) -> Result<P256Secret, AppError> {
self.root.derive_p256(&self.path_str)
}
pub fn multibase_pair(&self) -> Result<(String, Zeroizing<String>), AppError> {
let (public, private) = match self.key_type {
KeyType::Ed25519 => secret_pair(self.root.derive_ed25519(&self.path_str)?)?,
KeyType::X25519 => secret_pair(self.root.derive_x25519(&self.path_str)?)?,
KeyType::MlDsa44 => secret_pair(self.root.derive_ml_dsa_44(&self.path_str)?)?,
KeyType::MlDsa65 => secret_pair(self.root.derive_ml_dsa_65(&self.path_str)?)?,
KeyType::P256 => {
let p256 = self.root.derive_p256(&self.path_str)?;
let point = p256.secret_key.public_key().to_sec1_point(true);
(
encode_public_multibase(&KeyType::P256, point.as_bytes()),
encode_private_multibase(&KeyType::P256, &p256.secret_key.to_bytes()),
)
}
ref other => {
return Err(AppError::Validation(format!(
"key derivation does not support {other} yet"
)));
}
};
Ok((public, Zeroizing::new(private)))
}
}
fn secret_pair(secret: Secret) -> Result<(String, String), AppError> {
let public = secret
.get_public_keymultibase()
.map_err(|e| AppError::Internal(format!("{e}")))?;
let private = secret
.get_private_keymultibase()
.map_err(|e| AppError::Internal(format!("{e}")))?;
Ok((public, private))
}
pub fn is_fully_hardened(path: &DerivationPath) -> bool {
path.path().iter().all(|i: &ChildIndex| i.is_hardened())
}
#[cfg(test)]
mod tests {
use super::*;
use chrono::Utc;
use vta_sdk::keys::KeyStatus;
fn p(s: &str) -> DerivationPath {
s.parse().unwrap()
}
const CONTEXTS: &[(&str, &str)] = &[
("vta", "m/26'/2'/0'"),
("tenant-a", "m/26'/2'/1'"),
("tenant-a-child", "m/26'/2'/1'/0'"),
("tenant-j", "m/26'/2'/10'"),
];
fn record(ctx: Option<&str>, path: &str) -> KeyRecord {
KeyRecord {
key_id: "k".into(),
derivation_path: path.into(),
key_type: KeyType::Ed25519,
status: KeyStatus::Active,
public_key: String::new(),
label: None,
context_id: ctx.map(str::to_string),
exportable: None,
seed_id: Some(0),
origin: KeyOrigin::Derived,
created_at: Utc::now(),
updated_at: Utc::now(),
}
}
#[test]
fn containment_compares_indexes_not_strings() {
assert!(!is_strictly_within(
&p("m/26'/2'/1'"),
&p("m/26'/2'/10'/0'")
));
assert!(is_strictly_within(&p("m/26'/2'/1'"), &p("m/26'/2'/1'/0'")));
assert!(!is_strictly_within(&p("m/26'/2'/1'"), &p("m/26'/2'/1'")));
}
#[test]
fn owner_is_the_deepest_strict_base() {
let it = || CONTEXTS.iter().copied();
assert_eq!(owning_context(&p("m/26'/2'/1'/5'"), it()), Some("tenant-a"));
assert_eq!(
owning_context(&p("m/26'/2'/1'/0'/3'"), it()),
Some("tenant-a-child")
);
assert_eq!(owning_context(&p("m/26'/2'/1'/0'"), it()), Some("tenant-a"));
assert_eq!(
owning_context(&p("m/26'/2'/10'/0'"), it()),
Some("tenant-j")
);
assert_eq!(owning_context(&p("m/26'/0'/0'/0'"), it()), None);
}
#[test]
fn vti_key_032_explicit_path_must_belong_to_the_requested_context() {
let it = || CONTEXTS.iter().copied();
assert!(check_explicit_key_path("m/26'/2'/1'/7'", Some("tenant-a"), it()).is_ok());
for (path, ctx) in [
("m/26'/2'/0'/0'", Some("tenant-a")), ("m/26'/0'/0'/0'", Some("tenant-a")), ("m/26'/2'/1'/0'/2'", Some("tenant-a")), ("m/26'/2'/1'/7'", None), ] {
assert!(
matches!(
check_explicit_key_path(path, ctx, it()),
Err(CustodyViolation::ForeignPath { .. })
),
"{path} under {ctx:?} must be refused"
);
}
}
#[test]
fn no_record_may_be_created_in_the_delegated_subtree() {
for path in ["m/26'/9'", "m/26'/9'/0'"] {
assert!(matches!(
check_explicit_key_path(path, None, CONTEXTS.iter().copied()),
Err(CustodyViolation::InsideDelegatedRoot { .. })
));
}
}
#[test]
fn delegated_signing_is_confined_to_its_subtree_and_hardened() {
assert!(check_delegated_identity_path("m/26'/9'/0'").is_ok());
assert!(check_delegated_identity_path("m/26'/9'/3'/1'").is_ok());
for bad in [
"m/26'/9'",
"m/26'/2'/0'/0'",
"m/26'/0'/0'/0'",
"m/26'/9'/0",
"nonsense",
] {
assert!(check_delegated_identity_path(bad).is_err(), "{bad}");
}
}
#[test]
fn a_record_outside_its_context_is_refused_at_use() {
let planted = record(Some("tenant-a"), "m/26'/0'/0'/0'");
assert!(matches!(
authorize_record_derivation(&planted, Some("m/26'/2'/1'")),
Err(CustodyViolation::RecordOutsideContext { .. })
));
let honest = record(Some("tenant-a"), "m/26'/2'/1'/4'");
assert!(authorize_record_derivation(&honest, Some("m/26'/2'/1'")).is_ok());
assert!(matches!(
authorize_record_derivation(&honest, None),
Err(CustodyViolation::RecordContextMissing { .. })
));
assert!(authorize_record_derivation(&record(None, "m/26'/0'/0'/0'"), None).is_ok());
}
#[test]
fn a_referenced_key_must_be_in_the_resources_subtree() {
assert!(check_key_in_scope(&record(Some("acme"), "x"), "acme").is_ok());
assert!(check_key_in_scope(&record(Some("acme/eng"), "x"), "acme").is_ok());
for key_ctx in [None, Some("vta"), Some("acme-evil"), Some("other")] {
assert!(
matches!(
check_key_in_scope(&record(key_ctx, "x"), "acme"),
Err(CustodyViolation::KeyOutsideScope { .. })
),
"{key_ctx:?}"
);
}
assert!(check_key_in_scope(&record(Some("acme"), "x"), "acme/eng").is_err());
}
#[test]
fn non_derived_records_are_refused() {
let mut r = record(None, "internal");
r.origin = KeyOrigin::Imported;
assert!(matches!(
authorize_record_derivation(&r, None),
Err(CustodyViolation::NotDerived { .. })
));
}
}