use serde::{Deserialize, Serialize};
use serde_json::Value;
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct DeviceRegisterBody {
pub consumer_kind: Value,
pub display_name: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub platform: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub hpke_public_key: Option<String>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct DeviceHeartbeatBody {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub platform: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub vault_seq: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub ext: Option<Value>,
}
pub const EXT_DEVICE_NAME: &str = "org.openvtc.device-name";
#[must_use]
pub fn device_name_ext(display_name: &str) -> Value {
serde_json::json!({ EXT_DEVICE_NAME: { "displayName": display_name } })
}
pub const EXT_UV_KEY: &str = "org.openvtc.uv-key";
pub const EXT_UV_CONSENT: &str = "org.openvtc.uv-consent";
pub const EXT_UV_KEY_CHANGE: &str = "org.openvtc.uv-key-change";
pub const EXT_UV_APPROVERS: &str = "org.openvtc.uv-approvers";
pub const UV_PIN_DOMAIN: &str = "org.openvtc.uv-pin/v1|";
#[must_use]
pub fn uv_pin_grant_message(digest: &str) -> String {
format!("{UV_PIN_DOMAIN}{digest}")
}
pub const UV_PIN_CHANGE_PREFIX: &str = "org.openvtc.uv-pin/v1|change|";
pub const UV_PIN_MIN_ITERATIONS: u32 = 600_000;
pub const UV_PIN_MAX_ITERATIONS: u32 = 10_000_000;
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct UvKeyEnrolment {
#[serde(flatten)]
pub key: UvKeyMaterial,
pub hardware_backed: bool,
pub biometric_gated: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub attestation: Option<Value>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "camelCase")]
#[non_exhaustive]
pub enum UvKeyMaterial {
#[serde(rename_all = "camelCase")]
HardwareKey {
did: String,
},
#[serde(rename_all = "camelCase")]
Webauthn {
credential_id: String,
public_key_multibase: String,
rp_id: String,
origin: String,
},
#[serde(rename_all = "camelCase")]
PrfSealedKey {
did: String,
},
#[serde(rename_all = "camelCase")]
VtaPin {
salt: String,
iterations: u32,
verifier: PinVerifier,
},
#[serde(rename_all = "camelCase")]
RemoteApprover {
approver: String,
},
}
#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(transparent)]
pub struct PinVerifier(pub String);
impl std::fmt::Debug for PinVerifier {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str("PinVerifier(<redacted>)")
}
}
impl PinVerifier {
#[must_use]
pub fn key_bytes(&self) -> Option<[u8; 32]> {
decode_b64url(&self.0).and_then(|b| <[u8; 32]>::try_from(b.as_slice()).ok())
}
}
fn decode_b64url(s: &str) -> Option<Vec<u8>> {
use base64::Engine as _;
base64::engine::general_purpose::URL_SAFE_NO_PAD
.decode(s.trim_end_matches('='))
.ok()
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct UvKeyError(pub &'static str);
impl std::fmt::Display for UvKeyError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.0)
}
}
impl std::error::Error for UvKeyError {}
const MULTICODEC_P256: [u8; 2] = [0x80, 0x24];
const MULTICODEC_ED25519: [u8; 2] = [0xed, 0x01];
fn is_p256_multikey(multibase_value: &str) -> bool {
match multibase::decode(multibase_value) {
Ok((multibase::Base::Base58Btc, bytes)) => {
bytes.len() == 35 && bytes[..2] == MULTICODEC_P256 && matches!(bytes[2], 0x02 | 0x03)
}
_ => false,
}
}
fn is_ed25519_multikey(multibase_value: &str) -> bool {
match multibase::decode(multibase_value) {
Ok((multibase::Base::Base58Btc, bytes)) => {
bytes.len() == 34 && bytes[..2] == MULTICODEC_ED25519
}
_ => false,
}
}
impl UvKeyEnrolment {
pub fn from_ext_value(value: &Value) -> Result<Self, UvKeyError> {
let enrolment: Self = serde_json::from_value(value.clone())
.map_err(|_| UvKeyError("uv-key is not a UV key enrolment"))?;
let kept = serde_json::to_value(&enrolment)
.map_err(|_| UvKeyError("uv-key is not a UV key enrolment"))?;
let unknown = value
.as_object()
.into_iter()
.flatten()
.any(|(k, _)| kept.get(k).is_none());
if unknown {
return Err(UvKeyError(
"uv-key carries a member a UV key enrolment does not define",
));
}
enrolment.validate()?;
Ok(enrolment)
}
pub fn validate(&self) -> Result<(), UvKeyError> {
match &self.key {
UvKeyMaterial::HardwareKey { did } => {
let id = did
.strip_prefix("did:key:")
.ok_or(UvKeyError("a hardware UV key must be a did:key"))?;
if !(is_p256_multikey(id) || is_ed25519_multikey(id)) {
return Err(UvKeyError(
"a hardware UV key must be a P-256 or Ed25519 did:key",
));
}
if !self.biometric_gated {
return Err(UvKeyError(
"a hardware UV key must be biometric-gated; a key usable without user \
verification cannot approve a grant",
));
}
}
UvKeyMaterial::PrfSealedKey { did } => {
let id = did
.strip_prefix("did:key:")
.ok_or(UvKeyError("a PRF-sealed UV key must be a did:key"))?;
if !(is_p256_multikey(id) || is_ed25519_multikey(id)) {
return Err(UvKeyError(
"a PRF-sealed UV key must be a P-256 or Ed25519 did:key",
));
}
if self.hardware_backed {
return Err(UvKeyError(
"a PRF-sealed UV key is not hardware-backed; no secure element holds it",
));
}
}
UvKeyMaterial::VtaPin {
salt,
iterations,
verifier,
} => {
let salt = decode_b64url(salt)
.ok_or(UvKeyError("a PIN UV key's salt must be base64url"))?;
if !(16..=64).contains(&salt.len()) {
return Err(UvKeyError("a PIN UV key's salt must be 16 to 64 bytes"));
}
if !(UV_PIN_MIN_ITERATIONS..=UV_PIN_MAX_ITERATIONS).contains(iterations) {
return Err(UvKeyError(
"a PIN UV key's iterations must be 600000 to 10000000",
));
}
if verifier.key_bytes().is_none() {
return Err(UvKeyError(
"a PIN UV key's verifier must be exactly 32 bytes of base64url",
));
}
if self.hardware_backed || self.biometric_gated {
return Err(UvKeyError(
"a PIN UV key is neither hardware-backed nor biometric-gated",
));
}
}
UvKeyMaterial::RemoteApprover { approver } => {
let well_formed = approver.len() <= 2048
&& approver
.strip_prefix("did:")
.and_then(|rest| rest.split_once(':'))
.is_some_and(|(method, id)| {
!method.is_empty()
&& method
.chars()
.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit())
&& !id.is_empty()
})
&& !approver
.chars()
.any(|c| c.is_whitespace() || c.is_control());
if !well_formed {
return Err(UvKeyError("a remote approver must be a DID"));
}
if self.hardware_backed || self.biometric_gated {
return Err(UvKeyError(
"a remote-approver UV key names another device; it makes no hardware \
or biometric claim of its own",
));
}
}
UvKeyMaterial::Webauthn {
credential_id,
public_key_multibase,
rp_id,
origin,
} => {
use base64::Engine as _;
let raw = base64::engine::general_purpose::URL_SAFE_NO_PAD
.decode(credential_id)
.map_err(|_| UvKeyError("credentialId must be base64url without padding"))?;
if raw.is_empty() || raw.len() > 1023 {
return Err(UvKeyError("credentialId must be 1 to 1023 bytes"));
}
if !is_p256_multikey(public_key_multibase) {
return Err(UvKeyError(
"a passkey UV key must be a P-256 Multikey (ES256 credential)",
));
}
if rp_id.is_empty() || rp_id.len() > 253 || rp_id.contains(['/', ':', ' ']) {
return Err(UvKeyError("rpId must be a bare relying-party id"));
}
if !is_origin(origin) {
return Err(UvKeyError(
"origin must be a scheme://host[:port] origin with no path",
));
}
}
}
Ok(())
}
}
fn is_origin(origin: &str) -> bool {
let Some((scheme, rest)) = origin.split_once("://") else {
return false;
};
!scheme.is_empty()
&& scheme
.chars()
.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || matches!(c, '+' | '-' | '.'))
&& !rest.is_empty()
&& !rest.contains(['/', '?', '#', '@', ' '])
}
#[must_use]
pub fn uv_key_ext(enrolment: &UvKeyEnrolment) -> Value {
serde_json::json!({ EXT_UV_KEY: enrolment })
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct DeviceDisableBody {
pub device_id: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub reason: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct DeviceWipeBody {
pub device_id: String,
pub scope: String,
pub reason: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct WakeHandle {
pub gateway: String,
pub handle: String,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct DeviceSetWakeBody {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub wake_handle: Option<WakeHandle>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub suggested_triggers: Option<Vec<String>>,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_bare_heartbeat_is_an_empty_object() {
assert_eq!(
serde_json::to_value(DeviceHeartbeatBody::default()).expect("serialises"),
serde_json::json!({})
);
}
#[test]
fn a_named_heartbeat_carries_the_device_name_extension() {
let body = DeviceHeartbeatBody {
platform: None,
vault_seq: None,
ext: Some(device_name_ext("OpenVTC on new-host (default)")),
};
assert_eq!(
serde_json::to_value(&body).expect("serialises"),
serde_json::json!({
"ext": {
"org.openvtc.device-name": {
"displayName": "OpenVTC on new-host (default)"
}
}
})
);
}
const P256_DID_KEY: &str = "did:key:zDnaerDaTF5BXEavCrfRZEk316dpbLsfPDZ3WJ5hRTPFU2169";
fn hardware(did: &str, gated: bool) -> Value {
serde_json::json!({ "kind": "hardwareKey", "did": did,
"hardwareBacked": true, "biometricGated": gated })
}
#[test]
fn a_biometric_gated_hardware_key_enrols() {
let e = UvKeyEnrolment::from_ext_value(&hardware(P256_DID_KEY, true)).expect("valid");
assert!(matches!(e.key, UvKeyMaterial::HardwareKey { .. }));
assert_eq!(
serde_json::to_value(&e).unwrap(),
hardware(P256_DID_KEY, true)
);
UvKeyEnrolment::from_ext_value(&hardware(
"did:key:z6MkhaXgBZDvotDkL5257faiztiGiC2QtKLGpbnnEGta2doK",
true,
))
.expect("an Ed25519 did:key is accepted too");
}
#[test]
fn a_hardware_key_that_is_not_biometric_gated_is_refused() {
assert!(UvKeyEnrolment::from_ext_value(&hardware(P256_DID_KEY, false)).is_err());
}
#[test]
fn a_hardware_key_must_be_a_signing_did_key() {
for bad in [
"did:web:example.com",
"did:key:z6LSbysY2xFMRpGMhb7tFTLMpeuPRaqaWM1yECx2AtzE3KCc", "did:key:not-multibase",
] {
assert!(
UvKeyEnrolment::from_ext_value(&hardware(bad, true)).is_err(),
"{bad}"
);
}
}
fn prf_sealed(did: &str, hardware_backed: bool) -> Value {
serde_json::json!({ "kind": "prfSealedKey", "did": did,
"hardwareBacked": hardware_backed, "biometricGated": false })
}
#[test]
fn a_prf_sealed_key_enrols_as_its_own_kind() {
let ed = "did:key:z6MkhaXgBZDvotDkL5257faiztiGiC2QtKLGpbnnEGta2doK";
let e = UvKeyEnrolment::from_ext_value(&prf_sealed(ed, false)).expect("valid");
assert!(matches!(e.key, UvKeyMaterial::PrfSealedKey { .. }));
assert_eq!(serde_json::to_value(&e).unwrap(), prf_sealed(ed, false));
UvKeyEnrolment::from_ext_value(&prf_sealed(P256_DID_KEY, false))
.expect("a P-256 did:key is accepted too");
}
#[test]
fn a_prf_sealed_key_cannot_claim_hardware_backing() {
assert!(UvKeyEnrolment::from_ext_value(&prf_sealed(P256_DID_KEY, true)).is_err());
}
#[test]
fn a_prf_sealed_key_must_be_a_signing_did_key() {
for bad in [
"did:web:example.com",
"did:key:z6LSbysY2xFMRpGMhb7tFTLMpeuPRaqaWM1yECx2AtzE3KCc", ] {
assert!(
UvKeyEnrolment::from_ext_value(&prf_sealed(bad, false)).is_err(),
"{bad}"
);
}
}
fn passkey(origin: &str, key: &str) -> Value {
serde_json::json!({
"kind": "webauthn",
"credentialId": "AAECAwQFBgc",
"publicKeyMultibase": key,
"rpId": "abcdefghijklmnopabcdefghijklmnop",
"origin": origin,
"hardwareBacked": false,
"biometricGated": false,
})
}
#[test]
fn a_p256_passkey_enrols_and_others_are_refused() {
let key = P256_DID_KEY.strip_prefix("did:key:").unwrap();
UvKeyEnrolment::from_ext_value(&passkey(
"chrome-extension://abcdefghijklmnopabcdefghijklmnop",
key,
))
.expect("an ES256 passkey");
assert!(
UvKeyEnrolment::from_ext_value(&passkey(
"chrome-extension://abcdefghijklmnopabcdefghijklmnop",
"z6MkhaXgBZDvotDkL5257faiztiGiC2QtKLGpbnnEGta2doK",
))
.is_err()
);
for bad_origin in ["https://a.example/path", "a.example", "https://"] {
assert!(
UvKeyEnrolment::from_ext_value(&passkey(bad_origin, key)).is_err(),
"{bad_origin}"
);
}
}
fn b64(n: usize) -> String {
use base64::Engine as _;
base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(vec![7u8; n])
}
fn pin(salt: usize, iterations: u64, verifier: usize) -> Value {
serde_json::json!({
"kind": "vtaPin", "salt": b64(salt), "iterations": iterations,
"verifier": b64(verifier), "hardwareBacked": false, "biometricGated": false,
})
}
#[test]
fn a_pin_enrols_and_round_trips() {
let v = pin(16, 600_000, 32);
let e = UvKeyEnrolment::from_ext_value(&v).expect("valid");
assert!(matches!(e.key, UvKeyMaterial::VtaPin { .. }));
assert_eq!(serde_json::to_value(&e).unwrap(), v);
UvKeyEnrolment::from_ext_value(&pin(64, 10_000_000, 32)).expect("upper bounds");
}
#[test]
fn a_pin_out_of_bounds_is_refused() {
for bad in [
pin(15, 600_000, 32),
pin(65, 600_000, 32),
pin(16, 599_999, 32),
pin(16, 10_000_001, 32),
pin(16, 600_000, 31),
pin(16, 600_000, 33),
] {
assert!(UvKeyEnrolment::from_ext_value(&bad).is_err(), "{bad}");
}
let mut claims = pin(16, 600_000, 32);
claims["biometricGated"] = serde_json::json!(true);
assert!(UvKeyEnrolment::from_ext_value(&claims).is_err());
let mut claims = pin(16, 600_000, 32);
claims["hardwareBacked"] = serde_json::json!(true);
assert!(UvKeyEnrolment::from_ext_value(&claims).is_err());
let mut not_b64 = pin(16, 600_000, 32);
not_b64["verifier"] = serde_json::json!("not base64url!");
assert!(UvKeyEnrolment::from_ext_value(¬_b64).is_err());
}
#[test]
fn debug_never_prints_the_pin_verifier() {
let v = pin(16, 600_000, 32);
let e = UvKeyEnrolment::from_ext_value(&v).unwrap();
let printed = format!("{e:?}");
assert!(!printed.contains(&b64(32)), "{printed}");
assert!(printed.contains("redacted"), "{printed}");
}
fn remote(approver: &str) -> Value {
serde_json::json!({ "kind": "remoteApprover", "approver": approver,
"hardwareBacked": false, "biometricGated": false })
}
#[test]
fn a_remote_approver_must_be_a_did() {
let v = remote(P256_DID_KEY);
let e = UvKeyEnrolment::from_ext_value(&v).expect("valid");
assert!(matches!(e.key, UvKeyMaterial::RemoteApprover { .. }));
assert_eq!(serde_json::to_value(&e).unwrap(), v);
UvKeyEnrolment::from_ext_value(&remote("did:webvh:scid:example.com:phone"))
.expect("any DID method");
for bad in [
"",
"phone",
"did:",
"did::x",
"did:key:",
"did:key:a b",
"did:KEY:x",
] {
assert!(
UvKeyEnrolment::from_ext_value(&remote(bad)).is_err(),
"{bad}"
);
}
let mut claims = remote(P256_DID_KEY);
claims["biometricGated"] = serde_json::json!(true);
assert!(UvKeyEnrolment::from_ext_value(&claims).is_err());
}
#[test]
fn the_pin_messages_are_the_contracts() {
assert_eq!(uv_pin_grant_message("zAbc"), "org.openvtc.uv-pin/v1|zAbc");
assert_eq!(UV_PIN_CHANGE_PREFIX, "org.openvtc.uv-pin/v1|change|");
}
#[test]
fn unknown_members_and_kinds_are_refused() {
let mut v = hardware(P256_DID_KEY, true);
v["extra"] = serde_json::json!(1);
assert!(UvKeyEnrolment::from_ext_value(&v).is_err());
let mut v = hardware(P256_DID_KEY, true);
v["kind"] = serde_json::json!("softwareKey");
assert!(UvKeyEnrolment::from_ext_value(&v).is_err());
}
#[test]
fn the_uv_extension_keys_match_the_schema_pattern() {
for key in [
EXT_UV_KEY,
EXT_UV_CONSENT,
EXT_UV_KEY_CHANGE,
EXT_UV_APPROVERS,
] {
assert!(
key.split('.').count() >= 2
&& key.starts_with(|c: char| c.is_ascii_lowercase())
&& key.split('.').all(|s| !s.is_empty()
&& s.chars()
.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')),
"{key}"
);
}
}
#[test]
fn the_extension_key_matches_the_schema_pattern() {
let segments: Vec<&str> = EXT_DEVICE_NAME.split('.').collect();
assert!(segments.len() >= 2, "{EXT_DEVICE_NAME} needs a namespace");
assert!(
EXT_DEVICE_NAME.starts_with(|c: char| c.is_ascii_lowercase()),
"{EXT_DEVICE_NAME} must start with a lowercase letter"
);
for segment in segments {
assert!(
!segment.is_empty(),
"{EXT_DEVICE_NAME} has an empty segment"
);
assert!(
segment
.chars()
.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-'),
"{segment} may only hold [a-z0-9-]"
);
}
}
#[test]
fn an_unset_register_member_is_absent() {
let minimal = DeviceRegisterBody {
consumer_kind: serde_json::json!({"kind": "companion", "formFactor": "desktop"}),
display_name: "laptop".into(),
platform: None,
hpke_public_key: None,
};
assert_eq!(
serde_json::to_value(&minimal).expect("serialises"),
serde_json::json!({
"consumerKind": {"kind": "companion", "formFactor": "desktop"},
"displayName": "laptop",
})
);
}
#[test]
fn set_members_serialise_camel_case() {
let full = DeviceRegisterBody {
consumer_kind: serde_json::json!({"kind": "service", "serviceKind": "ai-agent"}),
display_name: "agent".into(),
platform: Some("macos".into()),
hpke_public_key: Some("zHpke".into()),
};
let v = serde_json::to_value(&full).expect("serialises");
assert_eq!(v.get("platform").and_then(Value::as_str), Some("macos"));
assert_eq!(
v.get("hpkePublicKey").and_then(Value::as_str),
Some("zHpke")
);
}
#[test]
fn a_wake_handle_nests_under_its_camel_case_member() {
let body = DeviceSetWakeBody {
wake_handle: Some(WakeHandle {
gateway: "apns".into(),
handle: "opaque".into(),
}),
suggested_triggers: Some(vec!["message".into()]),
};
assert_eq!(
serde_json::to_value(&body).expect("serialises"),
serde_json::json!({
"wakeHandle": {"gateway": "apns", "handle": "opaque"},
"suggestedTriggers": ["message"],
})
);
}
}