This repository has been archived on 2026-10-29. You can view files and clone it, but you cannot make any changes to its state, such as pushing and creating new issues, pull requests or comments.
Epiphany/epiphany-core/src/host_identity.rs
2026-07-19 16:42:06 +01:00

622 lines
22 KiB
Rust

use anyhow::{Context, Result, anyhow, bail};
use cultcache_rs::{
CacheBackingStore, CultCacheEnvelope, DatabaseEntry, SingleFileMessagePackBackingStore,
};
use ed25519_dalek::{Signature, Signer, SigningKey, Verifier, VerifyingKey};
use rand_core::OsRng;
use sha2::{Digest, Sha256};
use std::fs::OpenOptions;
use std::io::Write;
use std::path::{Path, PathBuf};
pub const HOST_IDENTITY_TYPE: &str = "epiphany.host_incarnation_identity.v0";
pub const HOST_IDENTITY_SCHEMA_VERSION: &str = "epiphany.host_incarnation_identity.v0";
pub const HOST_IDENTITY_KEY: &str = "host-incarnation";
pub const HOST_IDENTITY_TRUST_ANCHOR_TYPE: &str = "epiphany.host_identity_trust_anchor.v0";
pub const HOST_IDENTITY_TRUST_ANCHOR_KEY: &str = "host-incarnation-public";
pub const WINDOWS_HOST_IDENTITY_ASSURANCE: &str = "os_user_installation_bound_best_effort";
pub const LINUX_HOST_IDENTITY_ASSURANCE: &str = "os_installation_file_bound_cloneable_baseline";
const ID_DOMAIN: &[u8] = b"epiphany.host-incarnation.identity.v0\0";
const SIGNATURE_DOMAIN: &[u8] = b"epiphany.host-incarnation.signature.v0\0";
#[derive(Clone, Debug, PartialEq, DatabaseEntry)]
#[cultcache(
type = "epiphany.host_incarnation_identity.v0",
schema = "HostIncarnationIdentityEntry"
)]
pub struct HostIncarnationIdentityEntry {
#[cultcache(key = 0)]
pub schema_version: String,
#[cultcache(key = 1)]
pub identity_id: String,
#[cultcache(key = 2)]
pub public_key: Vec<u8>,
#[cultcache(key = 3)]
pub protected_private_seed: Vec<u8>,
#[cultcache(key = 4)]
pub protector_kind: String,
#[cultcache(key = 5)]
pub protector_binding: String,
#[cultcache(key = 6)]
pub protector_version: String,
#[cultcache(key = 7)]
pub assurance: String,
#[cultcache(key = 8)]
pub created_at: String,
#[cultcache(key = 9)]
pub enrollment_nonce: Vec<u8>,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct HostIdentitySignature {
pub identity_id: String,
pub signature: Vec<u8>,
}
#[derive(Clone, Debug, PartialEq, Eq, DatabaseEntry)]
#[cultcache(
type = "epiphany.host_identity_trust_anchor.v0",
schema = "HostIdentityTrustAnchorEntry"
)]
pub struct HostIdentityTrustAnchorEntry {
#[cultcache(key = 0)]
pub schema_version: String,
#[cultcache(key = 1)]
pub identity_id: String,
#[cultcache(key = 2)]
pub public_key: Vec<u8>,
#[cultcache(key = 3)]
pub assurance: String,
#[cultcache(key = 4)]
pub identity_created_at: String,
#[cultcache(key = 5)]
pub source_identity_record_sha256: String,
}
/// A deliberately narrow signing handle. The private seed is never exposed by
/// the public API and is unprotected only while this value exists.
pub struct HostIdentitySigner {
entry: HostIncarnationIdentityEntry,
signing_key: SigningKey,
}
impl HostIdentitySigner {
pub fn entry(&self) -> &HostIncarnationIdentityEntry {
&self.entry
}
pub fn sign(&self, purpose: &str, payload: &[u8]) -> Result<HostIdentitySignature> {
let message = signing_message(purpose, payload)?;
Ok(HostIdentitySignature {
identity_id: self.entry.identity_id.clone(),
signature: self.signing_key.sign(&message).to_bytes().to_vec(),
})
}
}
pub fn default_host_identity_store_path() -> Result<PathBuf> {
#[cfg(windows)]
{
let root = std::env::var_os("LOCALAPPDATA")
.ok_or_else(|| anyhow!("LOCALAPPDATA is unavailable; host identity path is unknown"))?;
return Ok(PathBuf::from(root)
.join("GameCult")
.join("Epiphany")
.join("host-identity.ccmp"));
}
#[cfg(target_os = "linux")]
{
let root = if let Some(root) = std::env::var_os("XDG_STATE_HOME") {
PathBuf::from(root)
} else {
let home = std::env::var_os("HOME")
.ok_or_else(|| anyhow!("HOME is unavailable; host identity path is unknown"))?;
PathBuf::from(home).join(".local").join("state")
};
return Ok(root
.join("gamecult")
.join("epiphany")
.join("host-identity.ccmp"));
}
#[allow(unreachable_code)]
Err(anyhow!(
"host identity enrollment is unsupported on this operating system"
))
}
pub fn enroll_default_host_identity() -> Result<HostIdentitySigner> {
enroll_host_identity_at(&default_host_identity_store_path()?)
}
pub fn open_default_host_identity() -> Result<HostIdentitySigner> {
open_host_identity_at(&default_host_identity_store_path()?)
}
pub fn export_host_identity_trust_anchor(
signer: &HostIdentitySigner,
output: &Path,
) -> Result<HostIdentityTrustAnchorEntry> {
let anchor = host_identity_trust_anchor(signer)?;
let entry = signer.entry();
prepare_parent(output)?;
let envelope = CultCacheEnvelope {
key: HOST_IDENTITY_TRUST_ANCHOR_KEY.into(),
r#type: HOST_IDENTITY_TRUST_ANCHOR_TYPE.into(),
payload: rmp_serde::to_vec(&anchor)?,
stored_at: entry.created_at.clone(),
schema_id: Some(HOST_IDENTITY_TRUST_ANCHOR_TYPE.into()),
};
let mut backing = SingleFileMessagePackBackingStore::new(output);
let existing = backing.pull_all()?;
match existing.as_slice() {
[] => backing.push(&envelope)?,
[current] if current == &envelope => {}
_ => bail!("host identity trust anchor output already contains different state"),
}
Ok(anchor)
}
pub fn export_raw_host_identity_trust_anchor(
signer: &HostIdentitySigner,
output: &Path,
) -> Result<HostIdentityTrustAnchorEntry> {
let anchor = host_identity_trust_anchor(signer)?;
let bytes = rmp_serde::to_vec(&anchor)?;
prepare_parent(output)?;
if output.exists() {
if std::fs::read(output)? != bytes {
bail!("raw host identity trust anchor output already contains different state");
}
return Ok(anchor);
}
let mut file = OpenOptions::new()
.write(true)
.create_new(true)
.open(output)?;
file.write_all(&bytes)?;
file.sync_all()?;
Ok(anchor)
}
fn host_identity_trust_anchor(signer: &HostIdentitySigner) -> Result<HostIdentityTrustAnchorEntry> {
let entry = signer.entry();
let anchor = HostIdentityTrustAnchorEntry {
schema_version: HOST_IDENTITY_TRUST_ANCHOR_TYPE.into(),
identity_id: entry.identity_id.clone(),
public_key: entry.public_key.clone(),
assurance: entry.assurance.clone(),
identity_created_at: entry.created_at.clone(),
source_identity_record_sha256: format!(
"sha256-{:x}",
Sha256::digest(rmp_serde::to_vec(entry)?)
),
};
Ok(anchor)
}
/// Performs one immutable enrollment. Existing state is never reused by this
/// operation and malformed state is never replaced.
pub fn enroll_host_identity_at(store_path: &Path) -> Result<HostIdentitySigner> {
if store_path.exists() {
bail!(
"host identity store {} already exists; enrollment is immutable",
store_path.display()
);
}
prepare_parent(store_path)?;
let mut seed = [0_u8; 32];
rand_core::RngCore::fill_bytes(&mut OsRng, &mut seed);
let signing_key = SigningKey::from_bytes(&seed);
let mut nonce = [0_u8; 32];
rand_core::RngCore::fill_bytes(&mut OsRng, &mut nonce);
let public_key = signing_key.verifying_key().to_bytes();
let identity_id = identity_id(&public_key);
let binding = platform_binding()?;
let protected_private_seed = protect_seed(signing_key.as_bytes(), &binding)?;
let entry = HostIncarnationIdentityEntry {
schema_version: HOST_IDENTITY_SCHEMA_VERSION.to_string(),
identity_id,
public_key: public_key.to_vec(),
protected_private_seed,
protector_kind: platform_protector_kind().to_string(),
protector_binding: binding,
protector_version: "v1".to_string(),
assurance: platform_assurance().to_string(),
created_at: chrono::Utc::now().to_rfc3339(),
enrollment_nonce: nonce.to_vec(),
};
validate_entry(&entry)?;
let envelope = entry_envelope(&entry)?;
let backing = SingleFileMessagePackBackingStore::new(store_path);
if !backing.insert_entry_if_absent(envelope)? {
bail!("host identity enrollment lost an atomic create race; refusing to reuse state");
}
harden_store_permissions(store_path)?;
open_host_identity_at(store_path)
}
pub fn open_host_identity_at(store_path: &Path) -> Result<HostIdentitySigner> {
if !store_path.is_file() {
bail!(
"host identity store {} does not exist",
store_path.display()
);
}
let entries = SingleFileMessagePackBackingStore::new(store_path).pull_all()?;
if entries.len() != 1 {
bail!("host identity store must contain exactly one immutable envelope");
}
let envelope = &entries[0];
if envelope.r#type != HOST_IDENTITY_TYPE || envelope.key != HOST_IDENTITY_KEY {
bail!("host identity store contains an unexpected type or key");
}
let entry: HostIncarnationIdentityEntry = rmp_serde::from_slice(&envelope.payload)
.context("host identity payload is malformed MessagePack")?;
validate_entry(&entry)?;
if entry.protector_kind != platform_protector_kind() || entry.assurance != platform_assurance()
{
bail!("host identity protector does not belong to this platform implementation");
}
let current_binding = platform_binding()?;
if entry.protector_binding != current_binding {
bail!("host identity protector binding does not match this OS installation");
}
let seed = unprotect_seed(&entry.protected_private_seed, &current_binding)?;
let seed: [u8; 32] = seed
.try_into()
.map_err(|_| anyhow!("unprotected host identity seed has invalid length"))?;
let signing_key = SigningKey::from_bytes(&seed);
if signing_key.verifying_key().to_bytes().as_slice() != entry.public_key.as_slice() {
bail!("host identity private seed does not match the enrolled public key");
}
Ok(HostIdentitySigner { entry, signing_key })
}
pub fn verify_host_identity_signature(
entry: &HostIncarnationIdentityEntry,
purpose: &str,
payload: &[u8],
proof: &HostIdentitySignature,
) -> Result<()> {
validate_entry(entry)?;
if proof.identity_id != entry.identity_id {
bail!("host identity signature names a different identity");
}
let public_key: [u8; 32] = entry
.public_key
.as_slice()
.try_into()
.map_err(|_| anyhow!("host identity public key has invalid length"))?;
let signature = Signature::from_slice(&proof.signature)
.map_err(|_| anyhow!("host identity signature has invalid length"))?;
VerifyingKey::from_bytes(&public_key)?
.verify(&signing_message(purpose, payload)?, &signature)
.map_err(|_| anyhow!("host identity signature verification failed"))
}
pub fn verify_host_identity_trust_anchor_signature(
anchor: &HostIdentityTrustAnchorEntry,
purpose: &str,
payload: &[u8],
proof: &HostIdentitySignature,
) -> Result<()> {
if anchor.schema_version != HOST_IDENTITY_TRUST_ANCHOR_TYPE
|| anchor.public_key.len() != 32
|| identity_id(&anchor.public_key) != anchor.identity_id
|| proof.identity_id != anchor.identity_id
{
bail!("host identity trust anchor or signature identity is invalid");
}
let public_key: [u8; 32] = anchor
.public_key
.as_slice()
.try_into()
.map_err(|_| anyhow!("host identity trust anchor public key has invalid length"))?;
let signature = Signature::from_slice(&proof.signature)
.map_err(|_| anyhow!("host identity signature has invalid length"))?;
VerifyingKey::from_bytes(&public_key)?
.verify(&signing_message(purpose, payload)?, &signature)
.map_err(|_| anyhow!("host identity signature verification failed"))
}
fn validate_entry(entry: &HostIncarnationIdentityEntry) -> Result<()> {
if entry.schema_version != HOST_IDENTITY_SCHEMA_VERSION
|| entry.public_key.len() != 32
|| entry.enrollment_nonce.len() != 32
|| entry.protected_private_seed.is_empty()
|| entry.protector_version != "v1"
{
bail!("host identity entry violates its fixed schema");
}
chrono::DateTime::parse_from_rfc3339(&entry.created_at)
.map_err(|_| anyhow!("host identity created_at is not RFC3339"))?;
if identity_id(&entry.public_key) != entry.identity_id {
bail!("host identity id does not match its public key");
}
Ok(())
}
fn identity_id(public_key: &[u8]) -> String {
hex(&Sha256::digest([ID_DOMAIN, public_key].concat()))
}
fn signing_message(purpose: &str, payload: &[u8]) -> Result<Vec<u8>> {
if purpose.trim().is_empty() {
bail!("host identity signature purpose must not be empty");
}
let mut message =
Vec::with_capacity(SIGNATURE_DOMAIN.len() + purpose.len() + payload.len() + 16);
message.extend_from_slice(SIGNATURE_DOMAIN);
message.extend_from_slice(&(purpose.len() as u64).to_be_bytes());
message.extend_from_slice(purpose.as_bytes());
message.extend_from_slice(&(payload.len() as u64).to_be_bytes());
message.extend_from_slice(payload);
Ok(message)
}
fn entry_envelope(entry: &HostIncarnationIdentityEntry) -> Result<CultCacheEnvelope> {
Ok(CultCacheEnvelope {
key: HOST_IDENTITY_KEY.to_string(),
r#type: HOST_IDENTITY_TYPE.to_string(),
payload: rmp_serde::to_vec(entry)?,
stored_at: entry.created_at.clone(),
schema_id: Some(HOST_IDENTITY_SCHEMA_VERSION.to_string()),
})
}
fn prepare_parent(path: &Path) -> Result<()> {
let parent = path
.parent()
.ok_or_else(|| anyhow!("host identity path has no parent"))?;
std::fs::create_dir_all(parent)?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(parent, std::fs::Permissions::from_mode(0o700))?;
}
Ok(())
}
fn harden_store_permissions(_path: &Path) -> Result<()> {
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(_path, std::fs::Permissions::from_mode(0o600))?;
}
Ok(())
}
#[cfg(windows)]
fn platform_protector_kind() -> &'static str {
"windows_dpapi_current_user"
}
#[cfg(target_os = "linux")]
fn platform_protector_kind() -> &'static str {
"linux_file_mode_machine_id_binding"
}
#[cfg(windows)]
fn platform_assurance() -> &'static str {
WINDOWS_HOST_IDENTITY_ASSURANCE
}
#[cfg(target_os = "linux")]
fn platform_assurance() -> &'static str {
LINUX_HOST_IDENTITY_ASSURANCE
}
#[cfg(windows)]
fn platform_binding() -> Result<String> {
Ok("dpapi-current-user:epiphany-host-identity-v1".to_string())
}
#[cfg(target_os = "linux")]
fn platform_binding() -> Result<String> {
let machine_id = std::fs::read_to_string("/etc/machine-id")
.or_else(|_| std::fs::read_to_string("/var/lib/dbus/machine-id"))
.context("Linux machine-id is unavailable")?;
let machine_id = machine_id.trim();
if machine_id.is_empty() {
bail!("Linux machine-id is empty");
}
Ok(format!(
"machine-id-sha256:{}",
hex(&Sha256::digest(machine_id.as_bytes()))
))
}
#[cfg(windows)]
fn protect_seed(seed: &[u8; 32], binding: &str) -> Result<Vec<u8>> {
dpapi(seed, binding, true)
}
#[cfg(windows)]
fn unprotect_seed(protected: &[u8], binding: &str) -> Result<Vec<u8>> {
dpapi(protected, binding, false)
}
#[cfg(windows)]
fn dpapi(input: &[u8], binding: &str, protect: bool) -> Result<Vec<u8>> {
use windows_sys::Win32::Foundation::LocalFree;
use windows_sys::Win32::Security::Cryptography::{
CRYPT_INTEGER_BLOB, CRYPTPROTECT_UI_FORBIDDEN, CryptProtectData, CryptUnprotectData,
};
let mut input = input.to_vec();
let mut entropy = Sha256::digest(
[
b"epiphany-host-identity-dpapi-v1\0".as_slice(),
binding.as_bytes(),
]
.concat(),
)
.to_vec();
let input_blob = CRYPT_INTEGER_BLOB {
cbData: input.len() as u32,
pbData: input.as_mut_ptr(),
};
let entropy_blob = CRYPT_INTEGER_BLOB {
cbData: entropy.len() as u32,
pbData: entropy.as_mut_ptr(),
};
let mut output = CRYPT_INTEGER_BLOB {
cbData: 0,
pbData: std::ptr::null_mut(),
};
let ok = unsafe {
if protect {
CryptProtectData(
&input_blob,
std::ptr::null(),
&entropy_blob,
std::ptr::null(),
std::ptr::null(),
CRYPTPROTECT_UI_FORBIDDEN,
&mut output,
)
} else {
CryptUnprotectData(
&input_blob,
std::ptr::null_mut(),
&entropy_blob,
std::ptr::null(),
std::ptr::null(),
CRYPTPROTECT_UI_FORBIDDEN,
&mut output,
)
}
};
if ok == 0 {
return Err(std::io::Error::last_os_error())
.context("DPAPI host identity operation failed");
}
let bytes =
unsafe { std::slice::from_raw_parts(output.pbData, output.cbData as usize).to_vec() };
unsafe {
LocalFree(output.pbData.cast());
}
Ok(bytes)
}
#[cfg(target_os = "linux")]
fn protect_seed(seed: &[u8; 32], binding: &str) -> Result<Vec<u8>> {
// This is intentionally labeled a cloneable baseline, not encryption. File
// modes protect the seed locally; the binding detects a copied installation.
let mask = Sha256::digest(
[
b"epiphany-linux-host-seed-v1\0".as_slice(),
binding.as_bytes(),
]
.concat(),
);
Ok(seed
.iter()
.zip(mask)
.map(|(byte, mask)| byte ^ mask)
.collect())
}
#[cfg(target_os = "linux")]
fn unprotect_seed(protected: &[u8], binding: &str) -> Result<Vec<u8>> {
if protected.len() != 32 {
bail!("protected Linux host seed has invalid length");
}
let mask = Sha256::digest(
[
b"epiphany-linux-host-seed-v1\0".as_slice(),
binding.as_bytes(),
]
.concat(),
);
Ok(protected
.iter()
.zip(mask)
.map(|(byte, mask)| byte ^ mask)
.collect())
}
fn hex(bytes: &[u8]) -> String {
const DIGITS: &[u8; 16] = b"0123456789abcdef";
let mut output = String::with_capacity(bytes.len() * 2);
for byte in bytes {
output.push(DIGITS[(byte >> 4) as usize] as char);
output.push(DIGITS[(byte & 0xf) as usize] as char);
}
output
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn enrollment_is_immutable_and_signatures_are_purpose_bound() -> Result<()> {
let temp = tempfile::tempdir()?;
let store = temp.path().join("host-identity.ccmp");
let signer = enroll_host_identity_at(&store)?;
let proof = signer.sign("test-receipt", b"payload")?;
verify_host_identity_signature(signer.entry(), "test-receipt", b"payload", &proof)?;
assert!(
verify_host_identity_signature(signer.entry(), "other", b"payload", &proof).is_err()
);
assert!(enroll_host_identity_at(&store).is_err());
let reopened = open_host_identity_at(&store)?;
assert_eq!(reopened.entry(), signer.entry());
Ok(())
}
#[test]
fn malformed_existing_store_fails_closed_without_regeneration() -> Result<()> {
let temp = tempfile::tempdir()?;
let store = temp.path().join("host-identity.ccmp");
std::fs::write(&store, b"not a CultCache store")?;
let before = std::fs::read(&store)?;
assert!(open_host_identity_at(&store).is_err());
assert!(enroll_host_identity_at(&store).is_err());
assert_eq!(std::fs::read(&store)?, before);
Ok(())
}
#[test]
fn exported_trust_anchor_is_public_only_and_immutable() -> Result<()> {
let temp = tempfile::tempdir()?;
let identity_store = temp.path().join("host-identity.ccmp");
let anchor_store = temp.path().join("host-identity-public.ccmp");
let signer = enroll_host_identity_at(&identity_store)?;
let anchor = export_host_identity_trust_anchor(&signer, &anchor_store)?;
assert_eq!(anchor.identity_id, signer.entry().identity_id);
assert_eq!(anchor.public_key, signer.entry().public_key);
let envelopes = SingleFileMessagePackBackingStore::new(&anchor_store).pull_all()?;
assert_eq!(envelopes.len(), 1);
assert_eq!(envelopes[0].r#type, HOST_IDENTITY_TRUST_ANCHOR_TYPE);
assert_eq!(envelopes[0].key, HOST_IDENTITY_TRUST_ANCHOR_KEY);
let decoded: HostIdentityTrustAnchorEntry = rmp_serde::from_slice(&envelopes[0].payload)?;
assert_eq!(decoded, anchor);
assert!(
!envelopes[0]
.payload
.windows(signer.entry().protected_private_seed.len())
.any(|window| window == signer.entry().protected_private_seed.as_slice())
);
let before = std::fs::read(&anchor_store)?;
export_host_identity_trust_anchor(&signer, &anchor_store)?;
assert_eq!(std::fs::read(&anchor_store)?, before);
Ok(())
}
#[test]
fn exported_raw_trust_anchor_is_the_six_field_crossing_artifact() -> Result<()> {
let temp = tempfile::tempdir()?;
let signer = enroll_host_identity_at(&temp.path().join("identity.ccmp"))?;
let output = temp.path().join("anchor.msgpack");
let anchor = export_raw_host_identity_trust_anchor(&signer, &output)?;
let decoded: HostIdentityTrustAnchorEntry =
rmp_serde::from_slice(&std::fs::read(&output)?)?;
assert_eq!(decoded, anchor);
let before = std::fs::read(&output)?;
export_raw_host_identity_trust_anchor(&signer, &output)?;
assert_eq!(std::fs::read(&output)?, before);
Ok(())
}
}