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";
#[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,
},
}
#[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::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 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}"
);
}
}
#[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] {
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"],
})
);
}
}