Add typed service signing identities to CultNet

This commit is contained in:
Meta Vens 2026-07-19 20:08:40 +01:00
commit c05e37ca6e
4 changed files with 576 additions and 0 deletions

2
Cargo.lock generated
View file

@ -216,6 +216,7 @@ dependencies = [
"base64",
"chrono",
"cultcache-rs",
"ed25519-dalek",
"hmac",
"rand",
"rmp-serde",
@ -226,6 +227,7 @@ dependencies = [
"sha2",
"socket2",
"uuid",
"windows-sys 0.61.2",
]
[[package]]

View file

@ -16,6 +16,7 @@ anyhow = "1"
base64 = "0.22"
chrono = { version = "0.4", features = ["serde"] }
cultcache-rs = { path = "../cultcache-rs" }
ed25519-dalek = "2.2"
hmac = "0.12"
rand = "0.9"
rmp-serde = "1.3"
@ -27,6 +28,9 @@ sha2 = "0.10"
socket2 = "0.5"
uuid = { version = "1", features = ["v4"] }
[target.'cfg(windows)'.dependencies]
windows-sys = { version = "0.61", features = ["Win32_Foundation", "Win32_Security_Cryptography"] }
[dev-dependencies]
pretty_assertions = "1.4"
tempfile = "3"

View file

@ -4,6 +4,7 @@ mod replication;
mod rudp;
mod schema_discovery;
mod security;
mod service_identity;
mod transport;
pub use contracts::*;
@ -12,4 +13,5 @@ pub use replication::*;
pub use rudp::*;
pub use schema_discovery::*;
pub use security::*;
pub use service_identity::*;
pub use transport::*;

View file

@ -0,0 +1,568 @@
use anyhow::{Context, Result, anyhow, bail};
use cultcache_rs::{
CacheBackingStore, CultCacheEnvelope, CultCacheExpectedEnvelope,
SingleFileMessagePackBackingStore,
};
use ed25519_dalek::{Signature, Signer, SigningKey, Verifier, VerifyingKey};
use rand::RngCore;
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use std::marker::PhantomData;
use std::path::Path;
pub const WINDOWS_SERVICE_IDENTITY_ASSURANCE: &str = "os_user_installation_bound_best_effort";
pub const LINUX_SERVICE_IDENTITY_ASSURANCE: &str = "os_installation_file_bound_cloneable_baseline";
/// Fixes every authority-bearing name at the call site. A service cannot open
/// another service's identity by supplying a convenient runtime string.
pub trait ServiceIdentityProfile: Send + Sync + 'static {
const PRIVATE_TYPE: &'static str;
const PRIVATE_SCHEMA: &'static str;
const PRIVATE_KEY: &'static str;
const TRUST_ANCHOR_TYPE: &'static str;
const TRUST_ANCHOR_SCHEMA: &'static str;
const TRUST_ANCHOR_KEY: &'static str;
const ID_DOMAIN: &'static [u8];
const SIGNATURE_DOMAIN: &'static [u8];
const PROTECTOR_CONTEXT: &'static str;
}
/// A purpose is a type, not caller-provided prose. Protocol implementations
/// define one zero-sized type per signed statement kind.
pub trait ServiceSignaturePurpose<P: ServiceIdentityProfile>: Send + Sync + 'static {
const PURPOSE: &'static [u8];
}
pub enum IdunnServiceIdentity {}
impl ServiceIdentityProfile for IdunnServiceIdentity {
const PRIVATE_TYPE: &'static str = "idunn.service_identity.private.v1";
const PRIVATE_SCHEMA: &'static str = "idunn.service_identity.private.v1";
const PRIVATE_KEY: &'static str = "idunn-service-identity";
const TRUST_ANCHOR_TYPE: &'static str = "idunn.service_identity.trust_anchor.v1";
const TRUST_ANCHOR_SCHEMA: &'static str = "idunn.service_identity.trust_anchor.v1";
const TRUST_ANCHOR_KEY: &'static str = "idunn-service-identity-public";
const ID_DOMAIN: &'static [u8] = b"idunn.service-identity.id.v1\0";
const SIGNATURE_DOMAIN: &'static [u8] = b"idunn.service-identity.signature.v1\0";
const PROTECTOR_CONTEXT: &'static str = "idunn-service-identity-v1";
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct ServiceIdentityPrivateEntry {
pub schema_version: String,
pub identity_id: String,
pub public_key: Vec<u8>,
pub protected_private_seed: Vec<u8>,
pub protector_kind: String,
pub protector_binding: String,
pub protector_version: String,
pub assurance: String,
pub created_at: String,
pub enrollment_nonce: Vec<u8>,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct ServiceIdentityTrustAnchor {
pub schema_version: String,
pub identity_id: String,
pub public_key: Vec<u8>,
pub assurance: String,
pub identity_created_at: String,
pub source_identity_record_sha256: String,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct ServiceIdentitySignature {
pub identity_id: String,
pub signature: Vec<u8>,
}
/// The only value holding unprotected key material. It exposes signing, never
/// seed export or access to the underlying signing key.
pub struct ServiceIdentitySigner<P: ServiceIdentityProfile> {
entry: ServiceIdentityPrivateEntry,
signing_key: SigningKey,
profile: PhantomData<P>,
}
impl<P: ServiceIdentityProfile> ServiceIdentitySigner<P> {
pub fn entry(&self) -> &ServiceIdentityPrivateEntry {
&self.entry
}
pub fn trust_anchor(&self) -> Result<ServiceIdentityTrustAnchor> {
Ok(ServiceIdentityTrustAnchor {
schema_version: P::TRUST_ANCHOR_SCHEMA.into(),
identity_id: self.entry.identity_id.clone(),
public_key: self.entry.public_key.clone(),
assurance: self.entry.assurance.clone(),
identity_created_at: self.entry.created_at.clone(),
source_identity_record_sha256: format!(
"sha256-{}",
hex(&Sha256::digest(rmp_serde::to_vec(&self.entry)?))
),
})
}
pub fn sign<S: ServiceSignaturePurpose<P>>(&self, payload: &[u8]) -> ServiceIdentitySignature {
ServiceIdentitySignature {
identity_id: self.entry.identity_id.clone(),
signature: self
.signing_key
.sign(&signing_message::<P, S>(payload))
.to_bytes()
.to_vec(),
}
}
}
pub fn enroll_service_identity_at<P: ServiceIdentityProfile>(
path: &Path,
) -> Result<ServiceIdentitySigner<P>> {
if path.exists() {
bail!(
"service identity store {} already exists; enrollment is immutable",
path.display()
);
}
prepare_parent(path)?;
let mut seed = [0u8; 32];
rand::rng().fill_bytes(&mut seed);
let signing_key = SigningKey::from_bytes(&seed);
let mut nonce = [0u8; 32];
rand::rng().fill_bytes(&mut nonce);
let public_key = signing_key.verifying_key().to_bytes();
let binding = platform_binding::<P>()?;
let entry = ServiceIdentityPrivateEntry {
schema_version: P::PRIVATE_SCHEMA.into(),
identity_id: identity_id::<P>(&public_key),
public_key: public_key.to_vec(),
protected_private_seed: protect_seed::<P>(&seed, &binding)?,
protector_kind: platform_protector_kind().into(),
protector_binding: binding,
protector_version: "v1".into(),
assurance: platform_assurance().into(),
created_at: chrono::Utc::now().to_rfc3339(),
enrollment_nonce: nonce.to_vec(),
};
validate_private::<P>(&entry)?;
let envelope = private_envelope::<P>(&entry)?;
let backing = SingleFileMessagePackBackingStore::new(path);
let expected = CultCacheExpectedEnvelope {
r#type: P::PRIVATE_TYPE.into(),
key: P::PRIVATE_KEY.into(),
current: None,
};
if !backing.compare_exchange(&[expected], &[envelope])? {
bail!("service identity enrollment lost atomic create race; refusing existing state");
}
harden_store_permissions(path)?;
open_service_identity_at::<P>(path)
}
pub fn open_service_identity_at<P: ServiceIdentityProfile>(
path: &Path,
) -> Result<ServiceIdentitySigner<P>> {
if !path.is_file() {
bail!("service identity store {} does not exist", path.display());
}
let entries = SingleFileMessagePackBackingStore::new(path).pull_all()?;
if entries.len() != 1 {
bail!("service identity store must contain exactly one immutable envelope");
}
let envelope = &entries[0];
if envelope.r#type != P::PRIVATE_TYPE
|| envelope.key != P::PRIVATE_KEY
|| envelope.schema_id.as_deref() != Some(P::PRIVATE_SCHEMA)
{
bail!("service identity store belongs to a different profile or schema");
}
let entry: ServiceIdentityPrivateEntry = rmp_serde::from_slice(&envelope.payload)
.context("service identity private payload is malformed MessagePack")?;
validate_private::<P>(&entry)?;
if entry.protector_kind != platform_protector_kind() || entry.assurance != platform_assurance()
{
bail!("service identity protector does not belong to this platform implementation");
}
let binding = platform_binding::<P>()?;
if entry.protector_binding != binding {
bail!("service identity protector binding does not match this OS installation or profile");
}
let seed: [u8; 32] = unprotect_seed::<P>(&entry.protected_private_seed, &binding)?
.try_into()
.map_err(|_| anyhow!("unprotected service 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!("service identity private seed does not match enrolled public key");
}
Ok(ServiceIdentitySigner {
entry,
signing_key,
profile: PhantomData,
})
}
pub fn export_service_identity_trust_anchor<P: ServiceIdentityProfile>(
signer: &ServiceIdentitySigner<P>,
path: &Path,
) -> Result<ServiceIdentityTrustAnchor> {
let anchor = signer.trust_anchor()?;
prepare_parent(path)?;
let envelope = CultCacheEnvelope {
key: P::TRUST_ANCHOR_KEY.into(),
r#type: P::TRUST_ANCHOR_TYPE.into(),
payload: rmp_serde::to_vec(&anchor)?,
stored_at: anchor.identity_created_at.clone(),
schema_id: Some(P::TRUST_ANCHOR_SCHEMA.into()),
};
let mut backing = SingleFileMessagePackBackingStore::new(path);
match backing.pull_all()?.as_slice() {
[] => backing.push(&envelope)?,
[current] if current == &envelope => {}
_ => bail!("service identity trust anchor output already contains different state"),
}
Ok(anchor)
}
pub fn verify_service_identity_signature<P, S>(
anchor: &ServiceIdentityTrustAnchor,
payload: &[u8],
proof: &ServiceIdentitySignature,
) -> Result<()>
where
P: ServiceIdentityProfile,
S: ServiceSignaturePurpose<P>,
{
validate_anchor::<P>(anchor)?;
if proof.identity_id != anchor.identity_id {
bail!("service identity signature names a different identity");
}
let key: [u8; 32] = anchor
.public_key
.as_slice()
.try_into()
.map_err(|_| anyhow!("service identity public key has invalid length"))?;
let signature = Signature::from_slice(&proof.signature)
.map_err(|_| anyhow!("service identity signature has invalid length"))?;
VerifyingKey::from_bytes(&key)?
.verify(&signing_message::<P, S>(payload), &signature)
.map_err(|_| anyhow!("service identity signature verification failed"))
}
fn validate_private<P: ServiceIdentityProfile>(entry: &ServiceIdentityPrivateEntry) -> Result<()> {
if entry.schema_version != P::PRIVATE_SCHEMA
|| entry.public_key.len() != 32
|| entry.enrollment_nonce.len() != 32
|| entry.protected_private_seed.is_empty()
|| entry.protector_version != "v1"
{
bail!("service identity private entry violates its profile schema");
}
chrono::DateTime::parse_from_rfc3339(&entry.created_at)
.map_err(|_| anyhow!("service identity created_at is not RFC3339"))?;
if identity_id::<P>(&entry.public_key) != entry.identity_id {
bail!("service identity id does not match public key");
}
Ok(())
}
fn validate_anchor<P: ServiceIdentityProfile>(anchor: &ServiceIdentityTrustAnchor) -> Result<()> {
if anchor.schema_version != P::TRUST_ANCHOR_SCHEMA
|| anchor.public_key.len() != 32
|| identity_id::<P>(&anchor.public_key) != anchor.identity_id
{
bail!("service identity trust anchor violates its profile schema");
}
Ok(())
}
fn identity_id<P: ServiceIdentityProfile>(key: &[u8]) -> String {
hex(&Sha256::digest([P::ID_DOMAIN, key].concat()))
}
fn signing_message<P: ServiceIdentityProfile, S: ServiceSignaturePurpose<P>>(
payload: &[u8],
) -> Vec<u8> {
let mut out =
Vec::with_capacity(P::SIGNATURE_DOMAIN.len() + S::PURPOSE.len() + payload.len() + 16);
out.extend_from_slice(P::SIGNATURE_DOMAIN);
out.extend_from_slice(&(S::PURPOSE.len() as u64).to_be_bytes());
out.extend_from_slice(S::PURPOSE);
out.extend_from_slice(&(payload.len() as u64).to_be_bytes());
out.extend_from_slice(payload);
out
}
fn private_envelope<P: ServiceIdentityProfile>(
entry: &ServiceIdentityPrivateEntry,
) -> Result<CultCacheEnvelope> {
Ok(CultCacheEnvelope {
key: P::PRIVATE_KEY.into(),
r#type: P::PRIVATE_TYPE.into(),
payload: rmp_serde::to_vec(entry)?,
stored_at: entry.created_at.clone(),
schema_id: Some(P::PRIVATE_SCHEMA.into()),
})
}
fn prepare_parent(path: &Path) -> Result<()> {
let parent = path
.parent()
.ok_or_else(|| anyhow!("service 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_SERVICE_IDENTITY_ASSURANCE
}
#[cfg(target_os = "linux")]
fn platform_assurance() -> &'static str {
LINUX_SERVICE_IDENTITY_ASSURANCE
}
#[cfg(windows)]
fn platform_binding<P: ServiceIdentityProfile>() -> Result<String> {
Ok(format!("dpapi-current-user:{}", P::PROTECTOR_CONTEXT))
}
#[cfg(target_os = "linux")]
fn platform_binding<P: ServiceIdentityProfile>() -> Result<String> {
let raw = 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 id = raw.trim();
if id.is_empty() {
bail!("Linux machine-id is empty");
}
Ok(format!(
"{}:machine-id-sha256:{}",
P::PROTECTOR_CONTEXT,
hex(&Sha256::digest(id.as_bytes()))
))
}
#[cfg(windows)]
fn protect_seed<P: ServiceIdentityProfile>(seed: &[u8; 32], binding: &str) -> Result<Vec<u8>> {
dpapi::<P>(seed, binding, true)
}
#[cfg(windows)]
fn unprotect_seed<P: ServiceIdentityProfile>(seed: &[u8], binding: &str) -> Result<Vec<u8>> {
dpapi::<P>(seed, binding, false)
}
#[cfg(windows)]
fn dpapi<P: ServiceIdentityProfile>(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"gamecult-service-identity-dpapi-v1\0".as_slice(),
P::PROTECTOR_CONTEXT.as_bytes(),
binding.as_bytes(),
]
.concat(),
)
.to_vec();
let ib = CRYPT_INTEGER_BLOB {
cbData: input.len() as u32,
pbData: input.as_mut_ptr(),
};
let eb = 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(
&ib,
std::ptr::null(),
&eb,
std::ptr::null(),
std::ptr::null(),
CRYPTPROTECT_UI_FORBIDDEN,
&mut output,
)
} else {
CryptUnprotectData(
&ib,
std::ptr::null_mut(),
&eb,
std::ptr::null(),
std::ptr::null(),
CRYPTPROTECT_UI_FORBIDDEN,
&mut output,
)
}
};
if ok == 0 {
return Err(std::io::Error::last_os_error())
.context("DPAPI service 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<P: ServiceIdentityProfile>(seed: &[u8; 32], binding: &str) -> Result<Vec<u8>> {
xor_linux::<P>(seed, binding)
}
#[cfg(target_os = "linux")]
fn unprotect_seed<P: ServiceIdentityProfile>(seed: &[u8], binding: &str) -> Result<Vec<u8>> {
if seed.len() != 32 {
bail!("protected Linux service seed has invalid length");
}
xor_linux::<P>(seed, binding)
}
#[cfg(target_os = "linux")]
fn xor_linux<P: ServiceIdentityProfile>(seed: &[u8], binding: &str) -> Result<Vec<u8>> {
let mask = Sha256::digest(
[
b"gamecult-linux-service-seed-v1\0".as_slice(),
P::PROTECTOR_CONTEXT.as_bytes(),
binding.as_bytes(),
]
.concat(),
);
Ok(seed.iter().zip(mask).map(|(a, b)| a ^ b).collect())
}
fn hex(bytes: &[u8]) -> String {
const D: &[u8; 16] = b"0123456789abcdef";
let mut s = String::with_capacity(bytes.len() * 2);
for b in bytes {
s.push(D[(b >> 4) as usize] as char);
s.push(D[(b & 15) as usize] as char);
}
s
}
#[cfg(test)]
mod tests {
use super::*;
enum StatusProjection {}
impl ServiceSignaturePurpose<IdunnServiceIdentity> for StatusProjection {
const PURPOSE: &'static [u8] = b"idunn.managed-health-projection.v1";
}
enum OtherPurpose {}
impl ServiceSignaturePurpose<IdunnServiceIdentity> for OtherPurpose {
const PURPOSE: &'static [u8] = b"idunn.other.v1";
}
#[test]
fn immutable_enrollment_reopens_and_is_purpose_and_payload_bound() -> Result<()> {
let temp = tempfile::tempdir()?;
let path = temp.path().join("idunn.ccmp");
let signer = enroll_service_identity_at::<IdunnServiceIdentity>(&path)?;
let proof = signer.sign::<StatusProjection>(b"healthy");
let anchor = signer.trust_anchor()?;
verify_service_identity_signature::<IdunnServiceIdentity, StatusProjection>(
&anchor, b"healthy", &proof,
)?;
assert!(
verify_service_identity_signature::<IdunnServiceIdentity, StatusProjection>(
&anchor,
b"unhealthy",
&proof
)
.is_err()
);
assert!(
verify_service_identity_signature::<IdunnServiceIdentity, OtherPurpose>(
&anchor, b"healthy", &proof
)
.is_err()
);
assert!(enroll_service_identity_at::<IdunnServiceIdentity>(&path).is_err());
assert_eq!(
open_service_identity_at::<IdunnServiceIdentity>(&path)?.entry(),
signer.entry()
);
Ok(())
}
#[test]
fn malformed_or_profile_substituted_state_fails_closed_without_replacement() -> Result<()> {
enum Alien {}
impl ServiceIdentityProfile for Alien {
const PRIVATE_TYPE: &'static str = "alien.private.v1";
const PRIVATE_SCHEMA: &'static str = "alien.private.v1";
const PRIVATE_KEY: &'static str = "alien";
const TRUST_ANCHOR_TYPE: &'static str = "alien.anchor.v1";
const TRUST_ANCHOR_SCHEMA: &'static str = "alien.anchor.v1";
const TRUST_ANCHOR_KEY: &'static str = "alien-public";
const ID_DOMAIN: &'static [u8] = b"alien.id\0";
const SIGNATURE_DOMAIN: &'static [u8] = b"alien.sig\0";
const PROTECTOR_CONTEXT: &'static str = "alien-v1";
}
let temp = tempfile::tempdir()?;
let malformed = temp.path().join("bad.ccmp");
std::fs::write(&malformed, b"rot")?;
let before = std::fs::read(&malformed)?;
assert!(open_service_identity_at::<IdunnServiceIdentity>(&malformed).is_err());
assert!(enroll_service_identity_at::<IdunnServiceIdentity>(&malformed).is_err());
assert_eq!(before, std::fs::read(&malformed)?);
let good = temp.path().join("good.ccmp");
enroll_service_identity_at::<IdunnServiceIdentity>(&good)?;
assert!(open_service_identity_at::<Alien>(&good).is_err());
Ok(())
}
#[test]
fn exported_anchor_is_public_only_immutable_and_rejects_identity_substitution() -> Result<()> {
let temp = tempfile::tempdir()?;
let signer =
enroll_service_identity_at::<IdunnServiceIdentity>(&temp.path().join("private.ccmp"))?;
let output = temp.path().join("public.ccmp");
let anchor = export_service_identity_trust_anchor(&signer, &output)?;
let bytes = std::fs::read(&output)?;
assert!(
!bytes
.windows(signer.entry().protected_private_seed.len())
.any(|w| w == signer.entry().protected_private_seed)
);
export_service_identity_trust_anchor(&signer, &output)?;
assert_eq!(bytes, std::fs::read(&output)?);
let other =
enroll_service_identity_at::<IdunnServiceIdentity>(&temp.path().join("other.ccmp"))?;
let proof = other.sign::<StatusProjection>(b"healthy");
assert!(
verify_service_identity_signature::<IdunnServiceIdentity, StatusProjection>(
&anchor, b"healthy", &proof
)
.is_err()
);
Ok(())
}
}