use std::{
collections::HashMap,
fs, io,
os::unix::fs::{OpenOptionsExt, PermissionsExt},
path::{Path, PathBuf},
};
use base64::Engine;
use rand::TryRngCore;
use serde::{Deserialize, Serialize};
use serde_json::json;
use sha2::{Digest, Sha256};
use subtle::ConstantTimeEq;
use time::OffsetDateTime;
use time::format_description::well_known::Rfc3339;
use super::{GLOBAL_SESSION_SUFFIX, RelayError, relay_error};
const PSK_BYTE_LENGTH: usize = 32;
const PRINCIPAL_FILE_MODE: u32 = 0o600;
const PRINCIPAL_STORE_FORMAT_VERSION: u32 = 1;
pub(super) fn canonical_session_id(session_id: &str, namespace: &str) -> String {
if session_id.ends_with(GLOBAL_SESSION_SUFFIX) {
session_id.to_string()
} else {
format!("{session_id}@{namespace}")
}
}
pub(super) fn bare_session_id(session_id: &str, namespace: &str) -> String {
if session_id.ends_with(GLOBAL_SESSION_SUFFIX) {
return session_id.to_string();
}
let qualifier = format!("@{namespace}");
session_id
.strip_suffix(qualifier.as_str())
.unwrap_or(session_id)
.to_string()
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub(crate) enum PrincipalType {
Session,
User,
Application,
Relay,
}
impl PrincipalType {
pub(crate) fn as_str(self) -> &'static str {
match self {
Self::Session => "session",
Self::User => "user",
Self::Application => "application",
Self::Relay => "relay",
}
}
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub(crate) struct PrincipalRecord {
pub(crate) principal_id: String,
pub(crate) principal_type: PrincipalType,
pub(crate) credential_hash: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub(crate) scope: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub(crate) expires_at: Option<String>,
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
pub(crate) metadata: HashMap<String, String>,
}
impl PrincipalRecord {
pub(crate) fn is_expired(&self, now: OffsetDateTime) -> bool {
record_is_expired(self, now)
}
}
#[derive(Clone, Debug, Default)]
pub(crate) struct PrincipalStore {
path: PathBuf,
records_by_hash: HashMap<String, PrincipalRecord>,
}
#[derive(Debug, Serialize, Deserialize)]
struct StoreEnvelope {
format_version: u32,
#[serde(default)]
principals: Vec<PrincipalRecord>,
}
impl PrincipalStore {
pub(crate) fn load(path: PathBuf) -> Result<Self, RelayError> {
let raw = match fs::read_to_string(&path) {
Ok(content) => content,
Err(error) if error.kind() == io::ErrorKind::NotFound => {
return Ok(Self {
path,
records_by_hash: HashMap::new(),
});
}
Err(source) => {
return Err(relay_error(
"internal_principal_store",
"failed to read principal store",
Some(json!({
"path": path.display().to_string(),
"cause": source.to_string(),
})),
));
}
};
let envelope: StoreEnvelope = serde_json::from_str(&raw).map_err(|source| {
relay_error(
"internal_principal_store",
"failed to parse principal store",
Some(json!({
"path": path.display().to_string(),
"cause": source.to_string(),
})),
)
})?;
if envelope.format_version != PRINCIPAL_STORE_FORMAT_VERSION {
return Err(relay_error(
"internal_principal_store",
"principal store has unsupported format-version",
Some(json!({
"path": path.display().to_string(),
"format_version": envelope.format_version,
"supported": PRINCIPAL_STORE_FORMAT_VERSION,
})),
));
}
let mut records_by_hash = HashMap::with_capacity(envelope.principals.len());
for record in envelope.principals {
records_by_hash.insert(record.credential_hash.clone(), record);
}
Ok(Self {
path,
records_by_hash,
})
}
pub(crate) fn find_by_credential_hash(&self, hash_hex: &str) -> Option<&PrincipalRecord> {
let probe = hash_hex.as_bytes();
for (stored_hash, record) in &self.records_by_hash {
if stored_hash.len() == probe.len() && bool::from(stored_hash.as_bytes().ct_eq(probe)) {
return Some(record);
}
}
None
}
pub(crate) fn records(&self) -> impl Iterator<Item = &PrincipalRecord> {
self.records_by_hash.values()
}
pub(crate) fn find_by_principal_id(&self, principal_id: &str) -> Option<&PrincipalRecord> {
self.records_by_hash
.values()
.find(|record| record.principal_id == principal_id)
}
pub(crate) fn insert(&mut self, record: PrincipalRecord) -> Option<PrincipalRecord> {
self.records_by_hash
.insert(record.credential_hash.clone(), record)
}
pub(crate) fn prune_expired(&mut self, now: OffsetDateTime) -> usize {
let before = self.records_by_hash.len();
self.records_by_hash
.retain(|_, record| !record_is_expired(record, now));
before - self.records_by_hash.len()
}
pub(crate) fn remove_by_principal_id(&mut self, principal_id: &str) -> Option<PrincipalRecord> {
let key = self
.records_by_hash
.iter()
.find(|(_, record)| record.principal_id == principal_id)
.map(|(hash, _)| hash.clone())?;
self.records_by_hash.remove(&key)
}
pub(crate) fn persist(&self) -> Result<(), RelayError> {
if let Some(parent) = self.path.parent() {
fs::create_dir_all(parent).map_err(|source| self.io_error("create parent", source))?;
let _ = fs::set_permissions(parent, fs::Permissions::from_mode(0o700));
}
let envelope = StoreEnvelope {
format_version: PRINCIPAL_STORE_FORMAT_VERSION,
principals: self.records_by_hash.values().cloned().collect(),
};
let serialized = serde_json::to_vec_pretty(&envelope).map_err(|source| {
relay_error(
"internal_principal_store",
"failed to serialize principal store",
Some(json!({
"path": self.path.display().to_string(),
"cause": source.to_string(),
})),
)
})?;
let tmp_path = self.path.with_extension("json.tmp");
{
let mut options = fs::OpenOptions::new();
options
.create(true)
.truncate(true)
.write(true)
.mode(PRINCIPAL_FILE_MODE);
let mut file = options
.open(&tmp_path)
.map_err(|source| self.io_error("open temp", source))?;
io::Write::write_all(&mut file, &serialized)
.map_err(|source| self.io_error("write temp", source))?;
file.sync_all()
.map_err(|source| self.io_error("sync temp", source))?;
}
fs::rename(&tmp_path, &self.path)
.map_err(|source| self.io_error("rename store", source))?;
fs::set_permissions(&self.path, fs::Permissions::from_mode(PRINCIPAL_FILE_MODE))
.map_err(|source| self.io_error("set mode 0600", source))?;
Ok(())
}
fn io_error(&self, context: &str, source: io::Error) -> RelayError {
relay_error(
"internal_principal_store",
"principal store io failure",
Some(json!({
"path": self.path.display().to_string(),
"context": context,
"cause": source.to_string(),
})),
)
}
}
pub(crate) fn generate_psk() -> String {
let mut bytes = [0u8; PSK_BYTE_LENGTH];
rand::rngs::OsRng
.try_fill_bytes(&mut bytes)
.expect("OsRng must not fail");
base64::engine::general_purpose::STANDARD_NO_PAD.encode(bytes)
}
pub(crate) fn write_psk_output_file(path: &Path, psk: &str) -> Result<(), RelayError> {
if !path.is_absolute() {
return Err(relay_error(
"validation_invalid_output_path",
"credential output path must be absolute",
Some(json!({ "path": path.display().to_string() })),
));
}
let Some(parent) = path.parent() else {
return Err(relay_error(
"validation_invalid_output_path",
"credential output path has no parent directory",
Some(json!({ "path": path.display().to_string() })),
));
};
if !parent.is_dir() {
return Err(relay_error(
"validation_invalid_output_path",
"credential output parent directory does not exist",
Some(json!({
"path": path.display().to_string(),
"parent": parent.display().to_string(),
})),
));
}
let mut options = fs::OpenOptions::new();
options
.create(true)
.truncate(true)
.write(true)
.mode(PRINCIPAL_FILE_MODE)
.custom_flags(libc::O_NOFOLLOW);
let mut file = options.open(path).map_err(|source| {
relay_error(
"validation_invalid_output_path",
"failed to write credential output file",
Some(json!({
"path": path.display().to_string(),
"cause": source.to_string(),
})),
)
})?;
io::Write::write_all(&mut file, psk.as_bytes()).map_err(|source| {
relay_error(
"internal_credential_write",
"failed to write credential output file",
Some(json!({
"path": path.display().to_string(),
"cause": source.to_string(),
})),
)
})?;
fs::set_permissions(path, fs::Permissions::from_mode(PRINCIPAL_FILE_MODE)).map_err(
|source| {
relay_error(
"internal_credential_write",
"failed to set credential output file mode",
Some(json!({
"path": path.display().to_string(),
"cause": source.to_string(),
})),
)
},
)?;
Ok(())
}
fn record_is_expired(record: &PrincipalRecord, now: OffsetDateTime) -> bool {
match record.expires_at.as_deref() {
None => false,
Some(raw) => match OffsetDateTime::parse(raw, &Rfc3339) {
Ok(expires_at) => expires_at <= now,
Err(_) => true,
},
}
}
pub(crate) fn hash_token_sha256(token: &str) -> String {
let mut hasher = Sha256::new();
hasher.update(token.as_bytes());
let digest = hasher.finalize();
let mut out = String::with_capacity(digest.len() * 2);
for byte in digest {
use std::fmt::Write;
let _ = write!(&mut out, "{byte:02x}");
}
out
}
pub(crate) fn split_principal_id(principal_id: &str) -> Option<(&str, &str)> {
let (local, namespace) = principal_id.rsplit_once('@')?;
if local.is_empty() || namespace.is_empty() {
return None;
}
Some((local, namespace))
}
pub(crate) fn scope_permits(scope: Option<&str>, target_principal_id: &str) -> bool {
let Some(scope) = scope else {
return false;
};
if scope == target_principal_id {
return true;
}
matches!(
split_principal_id(target_principal_id),
Some((_, namespace)) if scope == namespace
)
}
pub(crate) fn classify_principal_id(principal_id: &str) -> Option<PrincipalType> {
let (_, namespace) = split_principal_id(principal_id)?;
Some(match namespace {
"GLOBAL" => PrincipalType::User,
"EXTERNAL" => PrincipalType::Application,
"RELAY" => PrincipalType::Relay,
_ => PrincipalType::Session,
})
}
pub(crate) const SOCKET_TRUST_TOKEN: &str = "socket-trust";
#[derive(Clone, Debug)]
pub(crate) struct IdentityIntrospectRights {
pub(crate) scope: Option<String>,
}
pub(crate) struct VerifiedIdentity {
pub(crate) principal_type: PrincipalType,
pub(crate) store_backed: bool,
pub(crate) introspect_rights: Option<IdentityIntrospectRights>,
}
pub(crate) fn verify_hello_credential(
principal_id: &str,
identity_token: &str,
store: &PrincipalStore,
require_session_credentials: bool,
now: OffsetDateTime,
) -> Result<VerifiedIdentity, RelayError> {
let Some(claimed_type) = classify_principal_id(principal_id) else {
return Err(relay_error(
"validation_invalid_principal_id",
"hello principal_id is not in <id>@<namespace> form",
Some(json!({ "principal_id": principal_id })),
));
};
if identity_token == SOCKET_TRUST_TOKEN {
return verify_socket_trust(principal_id, claimed_type, require_session_credentials);
}
let hash = hash_token_sha256(identity_token);
let Some(record) = store.find_by_credential_hash(&hash) else {
return Err(relay_error(
"validation_unrecognized_credential",
"hello identity_token did not match any registered principal",
Some(json!({ "principal_id": principal_id })),
));
};
if record.principal_id != principal_id {
return Err(relay_error(
"validation_identity_binding_mismatch",
"presented credential is registered to a different principal_id",
Some(json!({
"principal_id": principal_id,
"registered_principal_id": record.principal_id,
})),
));
}
if record.is_expired(now) {
return Err(relay_error(
"runtime_identity_expired",
"identity credential has expired; re-register or rotate the credential",
Some(json!({
"principal_id": principal_id,
"expires_at": record.expires_at,
})),
));
}
let introspect_rights =
(record.principal_type == PrincipalType::Application).then(|| IdentityIntrospectRights {
scope: record.scope.clone(),
});
Ok(VerifiedIdentity {
principal_type: record.principal_type,
store_backed: true,
introspect_rights,
})
}
fn verify_socket_trust(
principal_id: &str,
claimed_type: PrincipalType,
require_session_credentials: bool,
) -> Result<VerifiedIdentity, RelayError> {
match claimed_type {
PrincipalType::Application | PrincipalType::Relay => Err(relay_error(
"validation_credential_required",
"application and relay principals require a registered credential",
Some(json!({ "principal_id": principal_id })),
)),
PrincipalType::Session | PrincipalType::User => {
if require_session_credentials {
return Err(relay_error(
"validation_credential_required",
"relay requires session credentials; socket-trust is not accepted",
Some(json!({ "principal_id": principal_id })),
));
}
Ok(VerifiedIdentity {
principal_type: claimed_type,
store_backed: false,
introspect_rights: None,
})
}
}
}