use std::sync::Arc;
use anyhow::{anyhow, Result};
use async_trait::async_trait;
use openrtc::native::{CertificateStore, DeviceSigner, StoredCertificate};
use serde::Deserialize;
use super::{required_app_tag, required_secure_record_key, DeviceKeySigner};
pub(crate) struct HostIdentityStorage(pub Arc<dyn DeviceKeySigner>);
impl DeviceSigner for HostIdentityStorage {
fn public_jwk(&self, app_tag: &str) -> Result<serde_json::Value> {
self.0.public_jwk(app_tag).map_err(|error| anyhow!(error))
}
fn sign(&self, app_tag: &str, challenge: &[u8]) -> Result<Vec<u8>> {
self.0
.sign(app_tag, challenge)
.map_err(|error| anyhow!(error))
}
}
fn certificate_key(app_tag: &str, principal: &str, device: &str) -> String {
format!("openrtc:v2:device-certificate:{app_tag}:{principal}:{device}")
}
fn session_key(hash: &str, device: &str) -> String {
format!("openrtc:v2:device-session:{hash}:{device}")
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct CachedCertificate {
principal_id: String,
token: String,
expires_at_ms: u64,
session_key_hash: Option<String>,
signing_public_jwk: Option<serde_json::Value>,
}
impl HostIdentityStorage {
fn read(&self, app_tag: &str, key: &str, device_id: &str) -> Result<Option<StoredCertificate>> {
required_app_tag(app_tag).map_err(|error| anyhow!(error))?;
required_secure_record_key(key).map_err(|error| anyhow!(error))?;
let Some(raw) = self
.0
.read_secure_record(app_tag, key)
.map_err(|error| anyhow!(error))?
else {
return Ok(None);
};
let cached: CachedCertificate = serde_json::from_str(&raw)?;
Ok(Some(StoredCertificate {
app_tag: app_tag.into(),
device_id: device_id.into(),
principal_id: cached.principal_id,
token: cached.token,
expires_at_ms: cached.expires_at_ms,
session_key_hash: cached.session_key_hash,
signing_public_jwk: cached.signing_public_jwk,
}))
}
fn write(&self, app_tag: &str, key: &str, value: &str) -> Result<()> {
required_app_tag(app_tag).map_err(|error| anyhow!(error))?;
required_secure_record_key(key).map_err(|error| anyhow!(error))?;
self.0
.write_secure_record(app_tag, key, value)
.map_err(|error| anyhow!(error))
}
}
#[async_trait]
impl CertificateStore for HostIdentityStorage {
async fn load(
&self,
app_tag: &str,
principal: &str,
device: &str,
) -> Result<Option<StoredCertificate>> {
Ok(self
.read(
app_tag,
&certificate_key(app_tag, principal, device),
device,
)?
.filter(|stored| stored.principal_id == principal))
}
async fn save(&self, stored: &StoredCertificate) -> Result<()> {
let value = serde_json::to_string(stored)?;
self.write(
&stored.app_tag,
&certificate_key(&stored.app_tag, &stored.principal_id, &stored.device_id),
&value,
)?;
if let Some(hash) = &stored.session_key_hash {
self.write(
&stored.app_tag,
&session_key(hash, &stored.device_id),
&value,
)?;
}
Ok(())
}
async fn load_for_session(
&self,
app_tag: &str,
hash: &str,
device: &str,
) -> Result<Option<StoredCertificate>> {
let Some(mut stored) = self.read(app_tag, &session_key(hash, device), device)? else {
return Ok(None);
};
if stored.session_key_hash.as_deref() != Some(hash) {
stored.session_key_hash = Some(hash.into());
self.save(&stored).await?;
}
Ok(Some(stored))
}
async fn remove(&self, app_tag: &str, principal: &str, device: &str) -> Result<()> {
if let Some(stored) = self.load(app_tag, principal, device).await? {
if let Some(hash) = stored.session_key_hash {
let key = session_key(&hash, device);
required_secure_record_key(&key).map_err(|error| anyhow!(error))?;
self.0
.delete_secure_record(app_tag, &key)
.map_err(|error| anyhow!(error))?;
}
}
self.0
.delete_secure_record(app_tag, &certificate_key(app_tag, principal, device))
.map_err(|error| anyhow!(error))
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::collections::HashMap;
use std::sync::Mutex;
#[derive(Default)]
struct HostStore(Mutex<HashMap<(String, String), String>>);
impl DeviceKeySigner for HostStore {
fn public_jwk(&self, _: &str) -> Result<serde_json::Value, String> {
Ok(serde_json::json!({"kty":"OKP", "crv":"Ed25519", "x":"host-public-key"}))
}
fn sign(&self, app: &str, bytes: &[u8]) -> Result<Vec<u8>, String> {
assert_eq!(app, "app_test");
assert_eq!(bytes, b"challenge");
Ok(vec![7; 64])
}
fn delete(&self, _: &str) -> Result<(), String> {
panic!("certificate removal must not delete the installation key")
}
fn read_secure_record(&self, app: &str, key: &str) -> Result<Option<String>, String> {
Ok(self
.0
.lock()
.unwrap()
.get(&(app.into(), key.into()))
.cloned())
}
fn write_secure_record(&self, app: &str, key: &str, value: &str) -> Result<(), String> {
self.0
.lock()
.unwrap()
.insert((app.into(), key.into()), value.into());
Ok(())
}
fn delete_secure_record(&self, app: &str, key: &str) -> Result<(), String> {
self.0.lock().unwrap().remove(&(app.into(), key.into()));
Ok(())
}
}
#[tokio::test]
async fn adopts_ts_certificate_and_removes_both_indexes() {
let host = Arc::new(HostStore::default());
let store = HostIdentityStorage(host.clone());
let value = serde_json::json!({
"principalId":"principal", "token":"fixture-certificate", "expiresAtMs":123456,
"signingPublicJwk":{"kty":"OKP", "crv":"Ed25519", "x":"issuer-key"}
})
.to_string();
host.write_secure_record("app_test", &session_key("session-hash", "device"), &value)
.unwrap();
host.write_secure_record("app_other", &session_key("session-hash", "device"), &value)
.unwrap();
let stored = store
.load_for_session("app_test", "session-hash", "device")
.await
.unwrap()
.unwrap();
assert_eq!(stored.app_tag, "app_test");
assert_eq!(stored.device_id, "device");
assert_eq!(stored.session_key_hash.as_deref(), Some("session-hash"));
assert_eq!(stored.token, "fixture-certificate");
assert_eq!(
store.load("app_test", "principal", "device").await.unwrap(),
Some(stored)
);
assert!(store
.load_for_session("app_test", "another-session", "device")
.await
.unwrap()
.is_none());
assert!(store
.load_for_session("app_test", "session-hash", "another-device")
.await
.unwrap()
.is_none());
store
.remove("app_test", "principal", "device")
.await
.unwrap();
assert!(store
.load_for_session("app_test", "session-hash", "device")
.await
.unwrap()
.is_none());
assert!(store
.load("app_test", "principal", "device")
.await
.unwrap()
.is_none());
assert!(host
.read_secure_record("app_other", &session_key("session-hash", "device"))
.unwrap()
.is_some());
}
#[tokio::test]
async fn preserves_native_fields_and_rejects_wrong_principal_or_corrupt_record() {
let host = Arc::new(HostStore::default());
let store = HostIdentityStorage(host.clone());
let stored = StoredCertificate {
app_tag: "app_test".into(),
principal_id: "principal".into(),
device_id: "device".into(),
token: "fixture".into(),
expires_at_ms: 123456,
session_key_hash: Some("hash".into()),
signing_public_jwk: Some(serde_json::json!({"x":"issuer-key"})),
};
store.save(&stored).await.unwrap();
assert_eq!(
store
.load_for_session("app_test", "hash", "device")
.await
.unwrap(),
Some(stored.clone())
);
host.write_secure_record(
"app_test",
&certificate_key("app_test", "other", "device"),
&serde_json::to_string(&stored).unwrap(),
)
.unwrap();
assert!(store
.load("app_test", "other", "device")
.await
.unwrap()
.is_none());
host.write_secure_record("app_test", &session_key("bad", "device"), "not-json")
.unwrap();
assert!(store
.load_for_session("app_test", "bad", "device")
.await
.is_err());
assert!(store
.load_for_session("app_test", "../bad", "device")
.await
.is_err());
let mut corrupt = stored;
corrupt.session_key_hash = Some("../bad".into());
host.write_secure_record(
"app_test",
&certificate_key("app_test", "principal", "device"),
&serde_json::to_string(&corrupt).unwrap(),
)
.unwrap();
assert!(store
.remove("app_test", "principal", "device")
.await
.is_err());
}
#[test]
fn delegates_proof_to_the_existing_host_key() {
let store = HostIdentityStorage(Arc::new(HostStore::default()));
assert_eq!(
DeviceSigner::public_jwk(&store, "app_test").unwrap()["x"],
"host-public-key"
);
assert_eq!(
DeviceSigner::sign(&store, "app_test", b"challenge").unwrap(),
vec![7; 64]
);
}
}