use std::collections::BTreeMap;
use std::fmt;
use std::net::{IpAddr, Ipv4Addr, Ipv6Addr};
use std::path::PathBuf;
use std::time::Duration;
use passless_config_doc::ConfigDoc;
use serde::{Deserialize, Serialize};
use super::ids::{CredentialRef, ProfileId};
use crate::error::{Error, Result};
const MAX_DURATION_SECS: u64 = 86_400 * 365;
const MIN_DURATION_SECS: u64 = 1;
const UHID_NAME_MAX: usize = 128;
const UHID_PHYS_MAX: usize = 64;
const UHID_UNIQ_MAX: usize = 64;
const DEVICE_NAME_MAX: usize = UHID_NAME_MAX - 1;
const DEVICE_PHYS_MAX: usize = UHID_PHYS_MAX - 1;
const DEVICE_UNIQ_MAX: usize = UHID_UNIQ_MAX - 1;
const HUMAN_DEVICE_NAME: &str = "virtual-fido";
const HUMAN_DEVICE_PHYS: &str = "virtual-fido-001";
const HUMAN_VENDOR_ID: u16 = 0x15d9;
const HUMAN_PRODUCT_ID: u16 = 0x0a37;
pub const ANY_RP_ID: &str = "*";
const DEFAULT_MAX_OPERATIONS: u16 = 64;
const MAX_OPERATIONS: u16 = 4096;
fn default_max_operations() -> u16 {
DEFAULT_MAX_OPERATIONS
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub enum CredentialSelection {
#[default]
Single,
FirstMatching,
Newest,
Credential(CredentialRef),
}
impl Serialize for CredentialSelection {
fn serialize<S>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error>
where
S: serde::Serializer,
{
let value = match self {
Self::Single => "single".to_string(),
Self::FirstMatching => "first-matching".to_string(),
Self::Newest => "newest".to_string(),
Self::Credential(reference) => format!("credential:{reference}"),
};
serializer.serialize_str(&value)
}
}
impl<'de> Deserialize<'de> for CredentialSelection {
fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
let value = String::deserialize(deserializer)?;
match value.as_str() {
"single" => Ok(Self::Single),
"first-matching" | "first_matching" => Ok(Self::FirstMatching),
"newest" => Ok(Self::Newest),
_ => value
.strip_prefix("credential:")
.ok_or_else(|| serde::de::Error::custom(
"credential_selection must be single, first-matching, newest, or credential:<ref>",
))
.and_then(|reference| {
CredentialRef::from_hex(reference)
.map(Self::Credential)
.map_err(serde::de::Error::custom)
}),
}
}
}
#[derive(Default, Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum HumanVerificationPrompt {
#[default]
Always,
WhenRequired,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum AgentMode {
#[serde(rename = "same-user", alias = "same_user")]
SameUser,
#[serde(rename = "isolated")]
Isolated,
}
impl fmt::Display for AgentMode {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
AgentMode::SameUser => write!(f, "same-user"),
AgentMode::Isolated => write!(f, "isolated"),
}
}
}
impl std::str::FromStr for AgentMode {
type Err = String;
fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
match s {
"same-user" | "same_user" => Ok(AgentMode::SameUser),
"isolated" => Ok(AgentMode::Isolated),
_ => Err(format!(
"Invalid agent mode '{}'. Must be: same-user, isolated",
s
)),
}
}
}
#[derive(Default, Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum CdpExposeMode {
#[default]
Pipe,
Port,
}
impl fmt::Display for CdpExposeMode {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Pipe => f.write_str("pipe"),
Self::Port => f.write_str("port"),
}
}
}
impl std::str::FromStr for CdpExposeMode {
type Err = String;
fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
match s {
"pipe" => Ok(CdpExposeMode::Pipe),
"port" => Ok(CdpExposeMode::Port),
_ => Err(format!(
"Invalid CDP expose mode '{}'. Must be: pipe, port",
s
)),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum AgentAuthorization {
Deny,
Confirm,
Allow,
}
impl fmt::Display for AgentAuthorization {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Deny => f.write_str("deny"),
Self::Confirm => f.write_str("confirm"),
Self::Allow => f.write_str("allow"),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum UserPresenceSource {
#[serde(rename = "human")]
Human,
#[serde(rename = "agent", alias = "policy")]
Agent,
#[serde(rename = "none")]
None,
}
impl fmt::Display for UserPresenceSource {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Human => f.write_str("human"),
Self::Agent => f.write_str("agent"),
Self::None => f.write_str("none"),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum UserVerificationSource {
#[serde(rename = "human")]
Human,
#[serde(rename = "agent", alias = "policy")]
Agent,
#[serde(rename = "none")]
None,
}
impl fmt::Display for UserVerificationSource {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Human => f.write_str("human"),
Self::Agent => f.write_str("agent"),
Self::None => f.write_str("none"),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, ConfigDoc)]
pub struct AgentCeremonyPolicy {
pub authorization: AgentAuthorization,
pub user_presence: UserPresenceSource,
pub user_verification: UserVerificationSource,
}
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct AgentCeremonyPolicyFields {
authorization: AgentAuthorization,
user_presence: UserPresenceSource,
user_verification: UserVerificationSource,
}
#[derive(Deserialize)]
#[serde(untagged)]
enum AgentCeremonyPolicyRepr {
Alias(String),
Fields(AgentCeremonyPolicyFields),
}
impl<'de> Deserialize<'de> for AgentCeremonyPolicy {
fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
let repr = AgentCeremonyPolicyRepr::deserialize(deserializer)?;
match repr {
AgentCeremonyPolicyRepr::Alias(alias) => match alias.as_str() {
"deny" => Ok(Self::deny()),
"autonomous" => Ok(Self::autonomous()),
"supervised" => Ok(Self::supervised()),
other => Err(serde::de::Error::custom(format!(
"unknown agent ceremony policy alias '{other}'; expected deny, autonomous, or supervised"
))),
},
AgentCeremonyPolicyRepr::Fields(fields) => Ok(Self {
authorization: fields.authorization,
user_presence: fields.user_presence,
user_verification: fields.user_verification,
}),
}
}
}
impl AgentCeremonyPolicy {
pub fn validate(&self, profile_id: &ProfileId, rp_id: &str, action: &str) -> Result<()> {
if self.authorization == AgentAuthorization::Deny
&& (self.user_presence != UserPresenceSource::None
|| self.user_verification != UserVerificationSource::None)
{
return Err(Error::Config(format!(
"agent profile '{}': {} policy for '{}' must use no UP or UV evidence when authorization is deny",
profile_id, action, rp_id,
)));
}
if self.authorization == AgentAuthorization::Allow
&& self.user_presence == UserPresenceSource::Human
{
return Err(Error::Config(format!(
"agent profile '{}': {} policy for '{}' cannot require human UP when authorization is allow",
profile_id, action, rp_id,
)));
}
Ok(())
}
pub fn legacy_confirm(require_uv: bool) -> Self {
Self {
authorization: AgentAuthorization::Confirm,
user_presence: UserPresenceSource::Human,
user_verification: if require_uv {
UserVerificationSource::Human
} else {
UserVerificationSource::None
},
}
}
pub fn deny() -> Self {
Self {
authorization: AgentAuthorization::Deny,
user_presence: UserPresenceSource::None,
user_verification: UserVerificationSource::None,
}
}
pub fn autonomous() -> Self {
Self {
authorization: AgentAuthorization::Allow,
user_presence: UserPresenceSource::Agent,
user_verification: UserVerificationSource::Agent,
}
}
pub fn supervised() -> Self {
Self {
authorization: AgentAuthorization::Confirm,
user_presence: UserPresenceSource::Human,
user_verification: UserVerificationSource::Human,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, ConfigDoc)]
#[serde(deny_unknown_fields)]
pub struct AgentRpRule {
pub rp_id: String,
pub register: AgentCeremonyPolicy,
pub authenticate: AgentCeremonyPolicy,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub credential_selection: Option<CredentialSelection>,
}
fn default_gpg_backend() -> String {
"gnupg-bin".to_string()
}
#[cfg(feature = "tpm")]
fn default_tcti() -> String {
"device:/dev/tpmrm0".to_string()
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "lowercase", deny_unknown_fields)]
pub enum AgentStorageConfig {
Local {
path: PathBuf,
pin_path: PathBuf,
},
Pass {
store_path: PathBuf,
path: String,
#[serde(default = "default_gpg_backend")]
gpg_backend: String,
pin_path: PathBuf,
},
#[cfg(feature = "tpm")]
Tpm {
path: PathBuf,
#[serde(default = "default_tcti")]
tcti: String,
pin_path: PathBuf,
#[serde(default)]
portable: bool,
},
}
impl AgentStorageConfig {
pub fn credential_state_path(&self) -> PathBuf {
match self {
Self::Local { path, .. } => crate::config::BackendConfig::canonicalize_path(path),
Self::Pass {
store_path, path, ..
} => crate::config::BackendConfig::canonicalize_path(&store_path.join(path)),
#[cfg(feature = "tpm")]
Self::Tpm { path, .. } => crate::config::BackendConfig::canonicalize_path(path),
}
}
pub fn pin_state_path(&self) -> PathBuf {
match self {
Self::Local { pin_path, .. } => {
crate::config::BackendConfig::canonicalize_path(pin_path)
}
Self::Pass {
store_path,
pin_path,
..
} => crate::config::BackendConfig::canonicalize_path(&store_path.join(pin_path)),
#[cfg(feature = "tpm")]
Self::Tpm { pin_path, .. } => crate::config::BackendConfig::canonicalize_path(pin_path),
}
}
pub fn to_backend_config(&self) -> crate::config::BackendConfig {
match self {
Self::Local { path, .. } => crate::config::BackendConfig::Local {
path: path.display().to_string(),
},
Self::Pass {
store_path,
path,
gpg_backend,
..
} => crate::config::BackendConfig::Pass {
store_path: store_path.display().to_string(),
path: path.clone(),
gpg_backend: gpg_backend.clone(),
},
#[cfg(feature = "tpm")]
Self::Tpm {
path,
tcti,
portable,
..
} => crate::config::BackendConfig::Tpm {
path: path.display().to_string(),
tcti: tcti.clone(),
portable: *portable,
},
}
}
pub fn all_paths(&self) -> Vec<(String, PathBuf)> {
vec![
("credential".to_string(), self.credential_state_path()),
("pin".to_string(), self.pin_state_path()),
]
}
pub fn validate(&self) -> Result<()> {
match self {
Self::Pass {
store_path,
path,
pin_path,
..
} => {
if pin_path.is_absolute() {
return Err(Error::Config(format!(
"pass pin_path must be a relative subpath within the password store, got absolute path: {}",
pin_path.display()
)));
}
for component in pin_path.components() {
match component {
std::path::Component::ParentDir => {
return Err(Error::Config(format!(
"pass pin_path must not contain path traversal ('..'): {}",
pin_path.display()
)));
}
std::path::Component::RootDir | std::path::Component::Prefix(_) => {
return Err(Error::Config(format!(
"pass pin_path must be a relative subpath: {}",
pin_path.display()
)));
}
_ => {}
}
}
let cred_canon =
crate::config::BackendConfig::canonicalize_path(&store_path.join(path));
let pin_canon =
crate::config::BackendConfig::canonicalize_path(&store_path.join(pin_path));
if cred_canon.starts_with(&pin_canon) || pin_canon.starts_with(&cred_canon) {
return Err(Error::Config(format!(
"pass pin_path '{}' overlaps with credential path '{}'",
pin_path.display(),
path
)));
}
}
Self::Local { .. } => {}
#[cfg(feature = "tpm")]
Self::Tpm { .. } => {}
}
Ok(())
}
}
impl fmt::Display for AgentStorageConfig {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Local { .. } => write!(f, "local"),
Self::Pass { .. } => write!(f, "pass"),
#[cfg(feature = "tpm")]
Self::Tpm { .. } => write!(f, "tpm"),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct BoundedDuration {
secs: u64,
}
impl BoundedDuration {
pub const MIN_SECS: u64 = MIN_DURATION_SECS;
pub const MAX_SECS: u64 = MAX_DURATION_SECS;
pub fn new(secs: u64) -> Result<Self> {
if secs < MIN_DURATION_SECS {
return Err(Error::Config(format!(
"duration must be at least {} seconds, got {}",
MIN_DURATION_SECS, secs
)));
}
if secs > MAX_DURATION_SECS {
return Err(Error::Config(format!(
"duration must be at most {} seconds, got {}",
MAX_DURATION_SECS, secs
)));
}
Ok(Self { secs })
}
pub fn as_secs(&self) -> u64 {
self.secs
}
pub fn as_duration(&self) -> Duration {
Duration::from_secs(self.secs)
}
}
impl Serialize for BoundedDuration {
fn serialize<S>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error>
where
S: serde::Serializer,
{
self.secs.serialize(serializer)
}
}
impl<'de> Deserialize<'de> for BoundedDuration {
fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
let secs = u64::deserialize(deserializer)?;
BoundedDuration::new(secs).map_err(serde::de::Error::custom)
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, ConfigDoc)]
#[serde(deny_unknown_fields)]
pub struct DeviceIdentity {
pub name: String,
pub phys: String,
pub uniq: String,
pub vendor_id: u16,
pub product_id: u16,
}
impl DeviceIdentity {
pub fn validate(&self, profile_id: &ProfileId) -> Result<()> {
validate_device_field(&self.name, "name", DEVICE_NAME_MAX, profile_id)?;
validate_device_field(&self.phys, "phys", DEVICE_PHYS_MAX, profile_id)?;
validate_device_field(&self.uniq, "uniq", DEVICE_UNIQ_MAX, profile_id)?;
if self.name == HUMAN_DEVICE_NAME
&& self.phys == HUMAN_DEVICE_PHYS
&& self.vendor_id == HUMAN_VENDOR_ID
&& self.product_id == HUMAN_PRODUCT_ID
{
return Err(Error::Config(format!(
"agent profile '{}': device identity collides with the human authenticator \
(virtual-fido / 0x{:04x}:0x{:04x})",
profile_id, HUMAN_VENDOR_ID, HUMAN_PRODUCT_ID,
)));
}
Ok(())
}
}
fn validate_device_field(
value: &str,
field: &'static str,
max: usize,
profile_id: &ProfileId,
) -> Result<()> {
if value.contains('\0') {
return Err(Error::Config(format!(
"agent profile '{}': device.{} must not contain NUL bytes",
profile_id, field,
)));
}
if value.len() > max {
return Err(Error::Config(format!(
"agent profile '{}': device.{} exceeds maximum length of {} bytes (got {}); \
limit reserves 1 byte for NUL terminator",
profile_id,
field,
max,
value.len(),
)));
}
Ok(())
}
#[derive(Debug, Clone, Serialize, Deserialize, ConfigDoc)]
#[serde(deny_unknown_fields)]
pub struct AgentProfileConfig {
pub mode: AgentMode,
pub principal_user: String,
#[serde(default)]
pub rp_ids: Vec<String>,
#[serde(default)]
pub require_uv: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub credential_refs: Option<Vec<CredentialRef>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub max_grant_ttl: Option<BoundedDuration>,
#[serde(
default,
alias = "session_ttl",
skip_serializing_if = "Option::is_none"
)]
pub max_session_ttl: Option<BoundedDuration>,
#[serde(default = "default_max_operations")]
pub max_operations: u16,
#[serde(default)]
pub credential_selection: CredentialSelection,
#[serde(default)]
pub human_verification_prompt: HumanVerificationPrompt,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub storage: Option<AgentStorageConfig>,
#[serde(default)]
pub registration_allowed: bool,
#[serde(default)]
pub rules: Vec<AgentRpRule>,
#[serde(default)]
pub device: DeviceIdentity,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub start_url: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub browser_command: Option<Vec<String>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub browser_user: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub browser_runtime_root: Option<PathBuf>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub browser_cdp_expose: Option<CdpExposeMode>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub browser_cdp_port: Option<u16>,
}
impl AgentProfileConfig {
pub fn effective_rules(&self) -> Vec<AgentRpRule> {
if !self.rules.is_empty() {
return self.rules.clone();
}
self.rp_ids
.iter()
.map(|rp_id| AgentRpRule {
credential_selection: None,
rp_id: rp_id.clone(),
register: if self.registration_allowed {
AgentCeremonyPolicy::legacy_confirm(self.require_uv)
} else {
AgentCeremonyPolicy::deny()
},
authenticate: AgentCeremonyPolicy::legacy_confirm(self.require_uv),
})
.collect()
}
pub fn allowed_rp_ids(&self) -> Vec<String> {
self.effective_rules()
.into_iter()
.filter(|rule| {
rule.register.authorization != AgentAuthorization::Deny
|| rule.authenticate.authorization != AgentAuthorization::Deny
})
.map(|rule| rule.rp_id)
.collect()
}
pub fn rule_for_rp(&self, rp_id: &str) -> Option<AgentRpRule> {
let normalized = validate_rp_id(rp_id).ok()?;
let rules = self.effective_rules();
if let Some(rule) = rules
.iter()
.find(|rule| normalize_rp_id(&rule.rp_id) == normalized)
{
return Some(rule.clone());
}
rules
.into_iter()
.find(|rule| normalize_rp_id(&rule.rp_id) == ANY_RP_ID)
}
pub fn credential_selection_for_rp(&self, rp_id: &str) -> CredentialSelection {
self.rule_for_rp(rp_id)
.and_then(|rule| rule.credential_selection)
.unwrap_or_else(|| self.credential_selection.clone())
}
pub fn allows_registration(&self) -> bool {
self.effective_rules()
.iter()
.any(|rule| rule.register.authorization != AgentAuthorization::Deny)
}
pub fn allows_authentication(&self) -> bool {
self.effective_rules()
.iter()
.any(|rule| rule.authenticate.authorization != AgentAuthorization::Deny)
}
pub fn requires_human_uv(&self) -> bool {
self.effective_rules().iter().any(|rule| {
rule.register.user_verification == UserVerificationSource::Human
|| rule.authenticate.user_verification == UserVerificationSource::Human
})
}
pub fn validate(&self, profile_id: &ProfileId) -> Result<()> {
if self.principal_user.is_empty() {
return Err(Error::Config(format!(
"agent profile '{}': principal_user must not be empty",
profile_id
)));
}
if self.max_operations == 0 || self.max_operations > MAX_OPERATIONS {
return Err(Error::Config(format!(
"agent profile '{}': max_operations must be between 1 and {}",
profile_id, MAX_OPERATIONS
)));
}
if let CredentialSelection::Credential(reference) = &self.credential_selection
&& self
.credential_refs
.as_ref()
.is_some_and(|refs| !refs.contains(reference))
{
return Err(Error::Config(format!(
"agent profile '{}': credential_selection reference must be included in credential_refs",
profile_id
)));
}
if !self.rules.is_empty()
&& (!self.rp_ids.is_empty() || self.registration_allowed || self.require_uv)
{
return Err(Error::Config(format!(
"agent profile '{}': explicit rules cannot be combined with legacy rp_ids, registration_allowed, or require_uv",
profile_id,
)));
}
let effective_rules = self.effective_rules();
let mut normalized_rules = std::collections::BTreeSet::new();
for rule in &effective_rules {
validate_agent_rp_rule_id(&rule.rp_id)?;
let normalized = rule.rp_id.trim().to_ascii_lowercase();
if !normalized_rules.insert(normalized) {
return Err(Error::Config(format!(
"agent profile '{}': duplicate RP rule for '{}'",
profile_id, rule.rp_id,
)));
}
rule.register
.validate(profile_id, &rule.rp_id, "registration")?;
rule.authenticate
.validate(profile_id, &rule.rp_id, "authentication")?;
if let Some(CredentialSelection::Credential(reference)) = &rule.credential_selection
&& self
.credential_refs
.as_ref()
.is_some_and(|refs| !refs.contains(reference))
{
return Err(Error::Config(format!(
"agent profile '{}': credential_selection reference for RP '{}' must be included in credential_refs",
profile_id, rule.rp_id,
)));
}
}
if let Some(wildcard_rule) = effective_rules
.iter()
.find(|rule| normalize_rp_id(&rule.rp_id) == ANY_RP_ID)
{
if self.rules.is_empty() {
return Err(Error::Config(format!(
"agent profile '{}': wildcard RP scope '*' requires explicit rules",
profile_id
)));
}
if self.mode != AgentMode::SameUser {
return Err(Error::Config(format!(
"agent profile '{}': wildcard RP scope '*' is only supported in same-user mode",
profile_id
)));
}
if self.credential_refs.is_some() {
return Err(Error::Config(format!(
"agent profile '{}': wildcard RP scope '*' requires credential_refs to be omitted for dynamic credential discovery",
profile_id
)));
}
if wildcard_rule.register.authorization != AgentAuthorization::Deny {
return Err(Error::Config(format!(
"agent profile '{}': wildcard RP scope '*' must deny registration",
profile_id
)));
}
if matches!(
&wildcard_rule.credential_selection,
Some(CredentialSelection::Credential(_))
) {
return Err(Error::Config(format!(
"agent profile '{}': wildcard RP scope '*' cannot select one RP-specific credential reference",
profile_id
)));
}
}
if let Some(ref cmd) = self.browser_command {
if cmd.is_empty() {
return Err(Error::Config(format!(
"agent profile '{}': browser_command must not be empty",
profile_id
)));
}
for (i, arg) in cmd.iter().enumerate() {
if arg.contains('\0') {
return Err(Error::Config(format!(
"agent profile '{}': browser_command[{}] must not contain NUL bytes",
profile_id, i,
)));
}
}
}
match self.mode {
AgentMode::SameUser => {
if self.storage.is_some() {
return Err(Error::Config(format!(
"agent profile '{}': same-user mode uses the human backend and must not specify storage",
profile_id
)));
}
}
AgentMode::Isolated => {
if self.device.name.is_empty()
|| self.device.phys.is_empty()
|| self.device.uniq.is_empty()
{
return Err(Error::Config(format!(
"agent profile '{}': isolated mode requires a complete device identity",
profile_id
)));
}
self.device.validate(profile_id)?;
if self.storage.is_none() {
return Err(Error::Config(format!(
"agent profile '{}': isolated mode requires storage backend configuration",
profile_id
)));
}
if let Some(ref storage) = self.storage {
storage.validate()?;
}
if self.browser_user.is_some() {
return Err(Error::Config(format!(
"agent profile '{}': isolated mode must not specify browser_user",
profile_id
)));
}
if self.browser_runtime_root.is_some() {
return Err(Error::Config(format!(
"agent profile '{}': isolated mode must not specify browser_runtime_root",
profile_id
)));
}
}
}
if let Some(ref url) = self.start_url
&& url.is_empty()
{
return Err(Error::Config(format!(
"agent profile '{}': start_url must not be empty",
profile_id
)));
}
Ok(())
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, ConfigDoc)]
#[serde(deny_unknown_fields)]
pub struct AgentConfig {
#[serde(default)]
pub enabled: bool,
#[serde(default)]
pub profiles: BTreeMap<String, AgentProfileConfig>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub audit_path: Option<PathBuf>,
#[serde(default)]
pub acknowledge_global_same_user: Vec<String>,
#[serde(default)]
pub acknowledge_same_user_registration: Vec<String>,
}
impl AgentConfig {
pub fn validate(&self, human_state_path: Option<&std::path::Path>) -> Result<()> {
if !self.enabled {
return Ok(());
}
if self.audit_path.is_none() {
return Err(Error::Config(
"agents.enabled = true requires agents.audit_path".to_string(),
));
}
for acknowledged in self
.acknowledge_global_same_user
.iter()
.chain(self.acknowledge_same_user_registration.iter())
{
if !self.profiles.contains_key(acknowledged) {
return Err(Error::Config(format!(
"agents dangerous-profile acknowledgement references unknown profile '{}'",
acknowledged,
)));
}
}
let mut validated_profiles: Vec<(ProfileId, &AgentProfileConfig)> = Vec::new();
for (name, profile) in &self.profiles {
let pid = ProfileId::new(name.as_str())
.map_err(|e| Error::Config(format!("invalid profile id '{}': {}", name, e)))?;
profile.validate(&pid)?;
let global_same_user = profile.mode == AgentMode::SameUser
&& profile.effective_rules().iter().any(|rule| {
normalize_rp_id(&rule.rp_id) == ANY_RP_ID
&& rule.authenticate.authorization != AgentAuthorization::Deny
});
if global_same_user
&& !self
.acknowledge_global_same_user
.iter()
.any(|ack| ack == name)
{
return Err(Error::Config(format!(
"agent profile '{}': global same-user authentication requires explicit operator acknowledgement; add '{}' to agents.acknowledge_global_same_user",
name, name,
)));
}
let human_backend_registration =
profile.mode == AgentMode::SameUser && profile.allows_registration();
if human_backend_registration
&& !self
.acknowledge_same_user_registration
.iter()
.any(|ack| ack == name)
{
return Err(Error::Config(format!(
"agent profile '{}': same-user registration mutates the human credential backend and requires explicit operator acknowledgement; add '{}' to agents.acknowledge_same_user_registration",
name, name,
)));
}
validated_profiles.push((pid, profile));
}
let mut all_roots: Vec<(String, PathBuf)> = Vec::new();
if let Some(ref audit) = self.audit_path {
all_roots.push((
"audit_path".to_string(),
crate::config::BackendConfig::canonicalize_path(audit),
));
}
for (pid, profile) in &validated_profiles {
if let Some(ref storage) = profile.storage {
for (suffix, path) in storage.all_paths() {
all_roots.push((format!("{}.storage.{}", pid, suffix), path));
}
}
}
for (i, (name_a, path_a)) in all_roots.iter().enumerate() {
for (name_b, path_b) in all_roots.iter().skip(i + 1) {
if path_a.starts_with(path_b) || path_b.starts_with(path_a) {
return Err(Error::Config(format!(
"agent roots {} ({}) and {} ({}) overlap",
name_a,
path_a.display(),
name_b,
path_b.display(),
)));
}
}
}
if let Some(human_path) = human_state_path {
let canonical_human = crate::config::BackendConfig::canonicalize_path(human_path);
for (name, agent_root) in &all_roots {
if agent_root.starts_with(&canonical_human)
|| canonical_human.starts_with(agent_root)
{
return Err(Error::Config(format!(
"agent {} ({}) overlaps with human backend state ({})",
name,
agent_root.display(),
canonical_human.display(),
)));
}
}
}
let mut device_keys: std::collections::HashSet<String> = std::collections::HashSet::new();
for (pid, profile) in &validated_profiles {
if profile.mode == AgentMode::SameUser {
continue;
}
let key = format!(
"{}:{}:{}:{}:{}",
profile.device.name,
profile.device.phys,
profile.device.uniq,
profile.device.vendor_id,
profile.device.product_id,
);
if !device_keys.insert(key) {
return Err(Error::Config(format!(
"agent profile '{}': device identity collides with another profile",
pid
)));
}
}
Ok(())
}
pub fn get_profile(&self, name: &str) -> Option<&AgentProfileConfig> {
self.profiles.get(name)
}
pub fn profiles_for_rp_id(&self, rp_id: &str) -> Vec<(&String, &AgentProfileConfig)> {
let Ok(normalized) = validate_rp_id(rp_id) else {
return Vec::new();
};
self.profiles
.iter()
.filter(|(_, profile)| {
profile.effective_rules().iter().any(|rule| {
let rule_rp = normalize_rp_id(&rule.rp_id);
rule_rp == normalized || rule_rp == ANY_RP_ID
})
})
.collect()
}
}
fn normalize_rp_id(raw: &str) -> String {
raw.trim().to_ascii_lowercase()
}
fn is_ipv4(s: &str) -> bool {
s.parse::<Ipv4Addr>().is_ok()
}
fn is_ipv6(s: &str) -> bool {
let stripped = s.strip_prefix('[').and_then(|r| r.strip_suffix(']'));
let candidate = stripped.unwrap_or(s);
candidate.parse::<Ipv6Addr>().is_ok()
}
fn is_ip_address(s: &str) -> bool {
s.parse::<IpAddr>().is_ok() || is_ipv4(s) || is_ipv6(s)
}
fn is_valid_dns_name(s: &str) -> bool {
if s.is_empty() {
return false;
}
let labels: Vec<&str> = s.split('.').collect();
if labels.is_empty() {
return false;
}
for label in &labels {
if label.is_empty() || label.len() > 63 {
return false;
}
if !label.chars().all(|c| c.is_ascii_alphanumeric() || c == '-') {
return false;
}
if label.starts_with('-') || label.ends_with('-') {
return false;
}
}
true
}
fn is_public_suffix(domain: &str) -> bool {
use psl::Psl;
psl::List.domain(domain.as_bytes()).is_none()
}
pub fn validate_agent_rp_rule_id(raw: &str) -> Result<String> {
let trimmed = raw.trim();
if trimmed == ANY_RP_ID {
return Ok(ANY_RP_ID.to_string());
}
validate_rp_id(trimmed)
}
pub fn validate_rp_id(raw: &str) -> Result<String> {
let trimmed = raw.trim();
if trimmed.is_empty() {
return Err(Error::Config("RP ID must not be empty".to_string()));
}
if trimmed.contains("://") {
return Err(Error::Config(format!(
"RP ID must not contain a scheme: '{}'",
trimmed
)));
}
if trimmed.contains('/') {
return Err(Error::Config(format!(
"RP ID must not contain a path: '{}'",
trimmed
)));
}
if let Some(colon_pos) = trimmed.rfind(':') {
let after_colon = &trimmed[colon_pos + 1..];
if !after_colon.is_empty() && after_colon.chars().all(|c| c.is_ascii_digit()) {
return Err(Error::Config(format!(
"RP ID must not contain a port: '{}'",
trimmed
)));
}
}
if trimmed.starts_with("*.") || trimmed.starts_with('*') {
return Err(Error::Config(format!(
"RP ID must not be a wildcard: '{}'",
trimmed
)));
}
if trimmed.ends_with('.') {
return Err(Error::Config(format!(
"RP ID must not have a trailing dot: '{}'",
trimmed
)));
}
let normalized = normalize_rp_id(trimmed);
if is_ip_address(&normalized) {
return Err(Error::Config(format!(
"RP ID must not be an IP address: '{}'",
normalized
)));
}
if !is_valid_dns_name(&normalized) {
return Err(Error::Config(format!(
"RP ID is not a valid DNS name: '{}'",
normalized
)));
}
if !normalized.contains('.') {
return Err(Error::Config(format!(
"RP ID must have at least two labels: '{}'",
normalized
)));
}
if is_public_suffix(&normalized) {
return Err(Error::Config(format!(
"RP ID must not be a public suffix: '{}'",
normalized
)));
}
Ok(normalized)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_rp_id_valid() {
assert_eq!(validate_rp_id("example.com").unwrap(), "example.com");
assert_eq!(
validate_rp_id("sub.example.com").unwrap(),
"sub.example.com"
);
assert_eq!(validate_rp_id("EXAMPLE.COM").unwrap(), "example.com");
}
#[test]
fn test_rp_id_reject_scheme() {
assert!(validate_rp_id("https://example.com").is_err());
assert!(validate_rp_id("http://example.com").is_err());
}
#[test]
fn test_rp_id_reject_port() {
assert!(validate_rp_id("example.com:443").is_err());
assert!(validate_rp_id("example.com:8080").is_err());
}
#[test]
fn test_rp_id_reject_path() {
assert!(validate_rp_id("example.com/path").is_err());
assert!(validate_rp_id("example.com/a/b").is_err());
}
#[test]
fn test_rp_id_reject_wildcard() {
assert!(validate_rp_id("*.example.com").is_err());
assert!(validate_rp_id("*").is_err());
}
#[test]
fn test_agent_rp_rule_accepts_only_global_wildcard() {
assert_eq!(validate_agent_rp_rule_id(" * ").unwrap(), ANY_RP_ID);
assert!(validate_agent_rp_rule_id("*.example.com").is_err());
}
#[test]
fn test_rp_id_reject_trailing_dot() {
assert!(validate_rp_id("example.com.").is_err());
}
#[test]
fn test_rp_id_reject_ipv4() {
assert!(validate_rp_id("192.168.1.1").is_err());
assert!(validate_rp_id("127.0.0.1").is_err());
}
#[test]
fn test_rp_id_reject_ipv6() {
assert!(validate_rp_id("::1").is_err());
assert!(validate_rp_id("[::1]").is_err());
assert!(validate_rp_id("2001:db8::1").is_err());
}
#[test]
fn test_rp_id_reject_empty() {
assert!(validate_rp_id("").is_err());
assert!(validate_rp_id(" ").is_err());
}
#[test]
fn test_rp_id_reject_single_label() {
assert!(validate_rp_id("localhost").is_err());
assert!(validate_rp_id("com").is_err());
}
#[test]
fn test_rp_id_reject_public_suffix() {
assert!(validate_rp_id("com").is_err());
assert!(validate_rp_id("co.uk").is_err());
assert!(validate_rp_id("github.io").is_err());
}
#[test]
fn test_rp_id_reject_invalid_dns() {
assert!(validate_rp_id("-example.com").is_err());
assert!(validate_rp_id("example-.com").is_err());
assert!(validate_rp_id("exam ple.com").is_err());
}
#[test]
fn test_bounded_duration_valid() {
let d = BoundedDuration::new(60).unwrap();
assert_eq!(d.as_secs(), 60);
assert_eq!(d.as_duration(), Duration::from_secs(60));
}
#[test]
fn test_bounded_duration_zero_rejected() {
assert!(BoundedDuration::new(0).is_err());
}
#[test]
fn test_bounded_duration_too_large() {
assert!(BoundedDuration::new(MAX_DURATION_SECS + 1).is_err());
}
#[test]
fn test_bounded_duration_boundary() {
assert!(BoundedDuration::new(MIN_DURATION_SECS).is_ok());
assert!(BoundedDuration::new(MAX_DURATION_SECS).is_ok());
}
#[test]
fn test_bounded_duration_serde_roundtrip() {
let d = BoundedDuration::new(120).unwrap();
let json = serde_json::to_string(&d).unwrap();
assert_eq!(json, "120");
let d2: BoundedDuration = serde_json::from_str(&json).unwrap();
assert_eq!(d, d2);
}
#[test]
fn test_bounded_duration_serde_rejects_zero() {
let result: std::result::Result<BoundedDuration, _> = serde_json::from_str("0");
assert!(result.is_err());
}
#[test]
fn test_agent_mode_serde_isolated() {
let mode = AgentMode::Isolated;
let json = serde_json::to_string(&mode).unwrap();
assert_eq!(json, "\"isolated\"");
let mode2: AgentMode = serde_json::from_str(&json).unwrap();
assert_eq!(mode, mode2);
}
#[test]
fn test_agent_mode_from_str() {
assert_eq!(
"isolated".parse::<AgentMode>().unwrap(),
AgentMode::Isolated
);
assert!("delegated".parse::<AgentMode>().is_err());
assert!("invalid".parse::<AgentMode>().is_err());
}
#[test]
fn test_agent_mode_serde_same_user() {
let mode = AgentMode::SameUser;
let json = serde_json::to_string(&mode).unwrap();
assert_eq!(json, "\"same-user\"");
assert_eq!(serde_json::from_str::<AgentMode>(&json).unwrap(), mode);
assert_eq!("same_user".parse::<AgentMode>().unwrap(), mode);
}
#[test]
fn test_ceremony_policy_aliases() {
let autonomous: AgentCeremonyPolicy = serde_json::from_str("\"autonomous\"").unwrap();
assert_eq!(autonomous, AgentCeremonyPolicy::autonomous());
let supervised: AgentCeremonyPolicy = serde_json::from_str("\"supervised\"").unwrap();
assert_eq!(supervised, AgentCeremonyPolicy::supervised());
let denied: AgentCeremonyPolicy = serde_json::from_str("\"deny\"").unwrap();
assert_eq!(denied, AgentCeremonyPolicy::deny());
}
#[test]
fn test_legacy_policy_evidence_alias_normalizes_to_agent() {
let policy: AgentCeremonyPolicy = serde_json::from_str(
r#"{"authorization":"allow","user_presence":"policy","user_verification":"policy"}"#,
)
.unwrap();
assert_eq!(policy.user_presence, UserPresenceSource::Agent);
assert_eq!(policy.user_verification, UserVerificationSource::Agent);
let serialized = serde_json::to_string(&policy).unwrap();
assert!(serialized.contains("\"agent\""));
assert!(!serialized.contains("\"policy\""));
}
#[test]
fn test_same_user_profile_parses_without_storage_or_device() {
let toml_str = r#"
enabled = true
audit_path = "/tmp/passless-agent-audit.jsonl"
[profiles.opencode]
mode = "same-user"
principal_user = "alice"
session_ttl = 600
max_operations = 16
[[profiles.opencode.rules]]
rp_id = "github.com"
authenticate = "autonomous"
register = "deny"
"#;
let config: AgentConfig = toml::from_str(toml_str).unwrap();
let profile = config.profiles.get("opencode").unwrap();
assert_eq!(profile.mode, AgentMode::SameUser);
assert_eq!(profile.max_operations, 16);
assert_eq!(profile.max_session_ttl.unwrap().as_secs(), 600);
assert!(profile.storage.is_none());
assert_eq!(
profile.rules[0].authenticate,
AgentCeremonyPolicy::autonomous()
);
assert!(config.validate(None).is_ok());
}
#[test]
fn test_same_user_wildcard_rule_matches_valid_rps_and_exact_rule_wins() {
let toml_str = r#"
enabled = true
audit_path = "/tmp/passless-agent-audit.jsonl"
acknowledge_global_same_user = ["opencode"]
[profiles.opencode]
mode = "same-user"
principal_user = "alice"
[[profiles.opencode.rules]]
rp_id = "*"
authenticate = "autonomous"
register = "deny"
[[profiles.opencode.rules]]
rp_id = "bank.example.com"
authenticate = "supervised"
register = "deny"
"#;
let config: AgentConfig = toml::from_str(toml_str).unwrap();
assert!(config.validate(None).is_ok());
let profile = config.profiles.get("opencode").unwrap();
assert_eq!(
profile.rule_for_rp("github.com").unwrap().authenticate,
AgentCeremonyPolicy::autonomous()
);
let exact = profile.rule_for_rp("bank.example.com").unwrap();
assert_eq!(exact.authenticate, AgentCeremonyPolicy::supervised());
assert_eq!(exact.register, AgentCeremonyPolicy::deny());
assert!(profile.rule_for_rp("com").is_none());
let matches = config.profiles_for_rp_id("github.com");
assert_eq!(matches.len(), 1);
assert_eq!(matches[0].0, "opencode");
let mut restricted = profile.clone();
restricted.credential_refs = Some(vec![CredentialRef::with_default_domain(b"restricted")]);
let err = restricted
.validate(&ProfileId::new("restricted").unwrap())
.unwrap_err();
assert!(err.to_string().contains("credential_refs to be omitted"));
let mut registering = profile.clone();
registering.rules[0].register = AgentCeremonyPolicy::autonomous();
let err = registering
.validate(&ProfileId::new("registering").unwrap())
.unwrap_err();
assert!(err.to_string().contains("must deny registration"));
let mut isolated = profile.clone();
isolated.mode = AgentMode::Isolated;
let err = isolated
.validate(&ProfileId::new("isolated-wildcard").unwrap())
.unwrap_err();
assert!(err.to_string().contains("only supported in same-user mode"));
let mut legacy = profile.clone();
legacy.rules.clear();
legacy.rp_ids = vec![ANY_RP_ID.to_string()];
let err = legacy
.validate(&ProfileId::new("legacy-wildcard").unwrap())
.unwrap_err();
assert!(err.to_string().contains("requires explicit rules"));
}
#[test]
fn test_same_user_profile_rejects_storage() {
let mut profile = make_isolated_profile();
profile.mode = AgentMode::SameUser;
profile.device = DeviceIdentity::default();
assert!(
profile
.validate(&ProfileId::new("same-user").unwrap())
.is_err()
);
}
#[test]
fn test_agent_config_default_disabled() {
let config = AgentConfig::default();
assert!(!config.enabled);
assert!(config.profiles.is_empty());
}
#[test]
fn test_agent_config_validate_disabled_skips_checks() {
let config = AgentConfig::default();
assert!(config.validate(None).is_ok());
}
fn make_isolated_profile() -> AgentProfileConfig {
AgentProfileConfig {
max_operations: 64,
credential_selection: CredentialSelection::Single,
human_verification_prompt: HumanVerificationPrompt::Always,
mode: AgentMode::Isolated,
principal_user: "test-user".to_string(),
rp_ids: vec!["example.com".to_string()],
require_uv: true,
credential_refs: None,
max_grant_ttl: None,
max_session_ttl: None,
storage: Some(AgentStorageConfig::Local {
path: PathBuf::from("/tmp/test-agent/creds"),
pin_path: PathBuf::from("/tmp/test-agent/pin"),
}),
registration_allowed: true,
rules: vec![],
device: DeviceIdentity {
name: "passless-agent-iso".to_string(),
phys: "iso-phys".to_string(),
uniq: "iso-uniq-001".to_string(),
vendor_id: 0x1234,
product_id: 0x5679,
},
start_url: None,
browser_command: None,
browser_user: None,
browser_runtime_root: None,
browser_cdp_expose: None,
browser_cdp_port: None,
}
}
#[test]
fn test_profile_isolated_requires_storage() {
let mut profile = make_isolated_profile();
profile.storage = None;
let pid = ProfileId::new("test").unwrap();
let err = profile.validate(&pid).unwrap_err();
assert!(err.to_string().contains("storage"));
}
#[test]
fn test_profile_isolated_valid() {
let profile = make_isolated_profile();
let pid = ProfileId::new("test").unwrap();
assert!(profile.validate(&pid).is_ok());
}
fn policy(
authorization: AgentAuthorization,
user_presence: UserPresenceSource,
user_verification: UserVerificationSource,
) -> AgentCeremonyPolicy {
AgentCeremonyPolicy {
authorization,
user_presence,
user_verification,
}
}
#[test]
fn test_explicit_policy_allow_validates() {
let mut profile = make_isolated_profile();
profile.rp_ids.clear();
profile.registration_allowed = false;
profile.require_uv = false;
profile.rules = vec![AgentRpRule {
credential_selection: None,
rp_id: "example.com".to_string(),
register: policy(
AgentAuthorization::Allow,
UserPresenceSource::Agent,
UserVerificationSource::Agent,
),
authenticate: policy(
AgentAuthorization::Confirm,
UserPresenceSource::Human,
UserVerificationSource::Human,
),
}];
assert!(profile.validate(&ProfileId::new("test").unwrap()).is_ok());
assert_eq!(profile.allowed_rp_ids(), vec!["example.com"]);
assert!(profile.allows_registration());
assert!(profile.requires_human_uv());
}
#[test]
fn test_explicit_policy_rejects_legacy_fields() {
let mut profile = make_isolated_profile();
profile.rules = vec![AgentRpRule {
credential_selection: None,
rp_id: "example.com".to_string(),
register: AgentCeremonyPolicy::deny(),
authenticate: AgentCeremonyPolicy::deny(),
}];
let err = profile
.validate(&ProfileId::new("test").unwrap())
.unwrap_err();
assert!(err.to_string().contains("cannot be combined"));
}
#[test]
fn test_allow_rejects_human_up() {
let err = policy(
AgentAuthorization::Allow,
UserPresenceSource::Human,
UserVerificationSource::None,
)
.validate(
&ProfileId::new("test").unwrap(),
"example.com",
"authentication",
)
.unwrap_err();
assert!(err.to_string().contains("cannot require human UP"));
}
#[test]
fn test_deny_rejects_evidence() {
let err = policy(
AgentAuthorization::Deny,
UserPresenceSource::Agent,
UserVerificationSource::None,
)
.validate(
&ProfileId::new("test").unwrap(),
"example.com",
"registration",
)
.unwrap_err();
assert!(err.to_string().contains("must use no UP or UV evidence"));
}
#[test]
fn test_duplicate_explicit_rp_rule_rejected() {
let mut profile = make_isolated_profile();
profile.rp_ids.clear();
profile.registration_allowed = false;
profile.require_uv = false;
let rule = AgentRpRule {
credential_selection: None,
rp_id: "example.com".to_string(),
register: AgentCeremonyPolicy::deny(),
authenticate: policy(
AgentAuthorization::Allow,
UserPresenceSource::Agent,
UserVerificationSource::None,
),
};
profile.rules = vec![rule.clone(), rule];
let err = profile
.validate(&ProfileId::new("test").unwrap())
.unwrap_err();
assert!(err.to_string().contains("duplicate RP rule"));
}
#[test]
fn test_explicit_rules_toml_roundtrip() {
let input = r#"
mode = "isolated"
principal_user = "agent"
[[rules]]
rp_id = "example.com"
register = { authorization = "deny", user_presence = "none", user_verification = "none" }
authenticate = { authorization = "allow", user_presence = "policy", user_verification = "policy" }
[storage.local]
path = "/tmp/rules/credentials"
pin_path = "/tmp/rules/pin"
[device]
name = "rules"
phys = "rules-phys"
uniq = "rules-uniq"
vendor_id = 4660
product_id = 22136
"#;
let profile: AgentProfileConfig = toml::from_str(input).unwrap();
assert_eq!(profile.rules.len(), 1);
assert_eq!(
profile.rules[0].authenticate.authorization,
AgentAuthorization::Allow
);
assert!(profile.validate(&ProfileId::new("rules").unwrap()).is_ok());
}
#[test]
fn test_config_deny_unknown_fields() {
let toml_str = r#"
enabled = true
unknown_field = "bad"
"#;
let result: std::result::Result<AgentConfig, _> = toml::from_str(toml_str);
assert!(result.is_err());
}
#[test]
fn test_config_profile_deny_unknown_fields() {
let toml_str = r#"
enabled = true
[profiles.test]
mode = "isolated"
principal_user = "u"
rp_ids = ["example.com"]
require_uv = true
extra_field = "bad"
[profiles.test.device]
name = "n"
phys = "p"
uniq = "u"
vendor_id = 1
product_id = 2
[profiles.test.storage.local]
path = "/tmp/c"
pin_path = "/tmp/p"
"#;
let result: std::result::Result<AgentConfig, _> = toml::from_str(toml_str);
assert!(result.is_err());
}
#[test]
fn test_config_toml_isolated_roundtrip() {
let toml_str = r#"
enabled = true
[profiles.release-bot]
mode = "isolated"
principal_user = "passless-release"
rp_ids = ["github.com"]
require_uv = true
registration_allowed = true
[profiles.release-bot.device]
name = "passless-agent-release"
phys = "release-phys"
uniq = "release-uniq"
vendor_id = 4660
product_id = 22137
[profiles.release-bot.storage.local]
path = "/var/lib/passless-agent/release/credentials"
pin_path = "/var/lib/passless-agent/release/pin"
"#;
let config: AgentConfig = toml::from_str(toml_str).unwrap();
let profile = config.profiles.get("release-bot").unwrap();
assert_eq!(profile.mode, AgentMode::Isolated);
assert!(profile.registration_allowed);
}
#[test]
fn test_profiles_for_rp_id_matches_explicit_rules() {
let toml_str = r#"
enabled = true
[profiles.release-bot]
mode = "isolated"
principal_user = "passless-release"
[[profiles.release-bot.rules]]
rp_id = "github.com"
register = { authorization = "deny", user_presence = "none", user_verification = "none" }
authenticate = { authorization = "allow", user_presence = "policy", user_verification = "policy" }
[profiles.release-bot.device]
name = "passless-agent-release"
phys = "release-phys"
uniq = "release-uniq"
vendor_id = 4660
product_id = 22137
[profiles.release-bot.storage.local]
path = "/var/lib/passless-agent/release/credentials"
pin_path = "/var/lib/passless-agent/release/pin"
"#;
let config: AgentConfig = toml::from_str(toml_str).unwrap();
let matches = config.profiles_for_rp_id("GITHUB.COM");
assert_eq!(matches.len(), 1);
assert_eq!(matches[0].0, "release-bot");
}
#[test]
fn test_config_no_agents_section_backward_compatible() {
let toml_str = r#"
backend_type = "local"
verbose = false
"#;
let result: std::result::Result<AgentConfig, _> = toml::from_str("");
assert!(result.is_ok());
let config = result.unwrap();
assert!(!config.enabled);
let _ = toml_str;
}
#[test]
fn test_config_device_identity_collision() {
let mut profiles = BTreeMap::new();
let device = DeviceIdentity {
name: "same".to_string(),
phys: "same".to_string(),
uniq: "same".to_string(),
vendor_id: 1,
product_id: 2,
};
profiles.insert(
"a".to_string(),
AgentProfileConfig {
max_operations: 64,
credential_selection: CredentialSelection::Single,
human_verification_prompt: HumanVerificationPrompt::Always,
mode: AgentMode::Isolated,
principal_user: "u1".to_string(),
rp_ids: vec!["a.com".to_string()],
require_uv: true,
credential_refs: None,
max_grant_ttl: None,
max_session_ttl: None,
storage: Some(AgentStorageConfig::Local {
path: PathBuf::from("/tmp/a/creds"),
pin_path: PathBuf::from("/tmp/a/pin"),
}),
registration_allowed: false,
rules: vec![],
device: device.clone(),
start_url: None,
browser_command: None,
browser_user: None,
browser_runtime_root: None,
browser_cdp_expose: None,
browser_cdp_port: None,
},
);
profiles.insert(
"b".to_string(),
AgentProfileConfig {
max_operations: 64,
credential_selection: CredentialSelection::Single,
human_verification_prompt: HumanVerificationPrompt::Always,
mode: AgentMode::Isolated,
principal_user: "u2".to_string(),
rp_ids: vec!["b.com".to_string()],
require_uv: true,
credential_refs: None,
max_grant_ttl: None,
max_session_ttl: None,
storage: Some(AgentStorageConfig::Local {
path: PathBuf::from("/tmp/b/creds"),
pin_path: PathBuf::from("/tmp/b/pin"),
}),
registration_allowed: false,
rules: vec![],
device,
start_url: None,
browser_command: None,
browser_user: None,
browser_runtime_root: None,
browser_cdp_expose: None,
browser_cdp_port: None,
},
);
let config = AgentConfig {
enabled: true,
profiles,
audit_path: Some(PathBuf::from("/tmp/agent-audit")),
acknowledge_global_same_user: vec![],
acknowledge_same_user_registration: vec![],
};
let err = config.validate(None).unwrap_err();
assert!(err.to_string().contains("collides"));
}
#[test]
fn test_config_overlapping_roots_rejected() {
let mut profiles = BTreeMap::new();
profiles.insert(
"a".to_string(),
AgentProfileConfig {
max_operations: 64,
credential_selection: CredentialSelection::Single,
human_verification_prompt: HumanVerificationPrompt::Always,
mode: AgentMode::Isolated,
principal_user: "u1".to_string(),
rp_ids: vec!["a.com".to_string()],
require_uv: true,
credential_refs: None,
max_grant_ttl: None,
max_session_ttl: None,
storage: Some(AgentStorageConfig::Local {
path: PathBuf::from("/tmp/agent/data"),
pin_path: PathBuf::from("/tmp/agent/data/sub"),
}),
registration_allowed: false,
rules: vec![],
device: DeviceIdentity {
name: "a".to_string(),
phys: "a".to_string(),
uniq: "a".to_string(),
vendor_id: 1,
product_id: 1,
},
start_url: None,
browser_command: None,
browser_user: None,
browser_runtime_root: None,
browser_cdp_expose: None,
browser_cdp_port: None,
},
);
let config = AgentConfig {
enabled: true,
profiles,
audit_path: Some(PathBuf::from("/tmp/agent-audit-roots")),
acknowledge_global_same_user: vec![],
acknowledge_same_user_registration: vec![],
};
let err = config.validate(None).unwrap_err();
assert!(err.to_string().contains("overlap"));
}
#[test]
fn test_config_agent_root_overlaps_human_rejected() {
let dir = tempfile::tempdir().unwrap();
let human_path = dir.path().join("human");
std::fs::create_dir_all(&human_path).unwrap();
let mut profiles = BTreeMap::new();
profiles.insert(
"a".to_string(),
AgentProfileConfig {
max_operations: 64,
credential_selection: CredentialSelection::Single,
human_verification_prompt: HumanVerificationPrompt::Always,
mode: AgentMode::Isolated,
principal_user: "u1".to_string(),
rp_ids: vec!["a.com".to_string()],
require_uv: true,
credential_refs: None,
max_grant_ttl: None,
max_session_ttl: None,
storage: Some(AgentStorageConfig::Local {
path: human_path.join("creds"),
pin_path: dir.path().join("pin"),
}),
registration_allowed: false,
rules: vec![],
device: DeviceIdentity {
name: "a".to_string(),
phys: "a".to_string(),
uniq: "a".to_string(),
vendor_id: 1,
product_id: 1,
},
start_url: None,
browser_command: None,
browser_user: None,
browser_runtime_root: None,
browser_cdp_expose: None,
browser_cdp_port: None,
},
);
let config = AgentConfig {
enabled: true,
profiles,
audit_path: Some(dir.path().join("audit")),
acknowledge_global_same_user: vec![],
acknowledge_same_user_registration: vec![],
};
let err = config.validate(Some(human_path.as_path())).unwrap_err();
assert!(err.to_string().contains("overlaps with human"));
}
#[test]
fn test_config_unknown_mode_rejected() {
let toml_str = r#"
enabled = true
[profiles.test]
mode = "delegated"
principal_user = "u"
rp_ids = ["example.com"]
require_uv = true
[profiles.test.device]
name = "n"
phys = "p"
uniq = "u"
vendor_id = 1
product_id = 2
"#;
let result: std::result::Result<AgentConfig, _> = toml::from_str(toml_str);
assert!(result.is_err());
}
#[test]
fn test_credential_ref_not_raw_id() {
use super::super::ids::CredentialRef;
let _cred_ref = CredentialRef::with_default_domain(b"test-credential");
assert_eq!(_cred_ref.as_bytes().len(), 32);
}
#[test]
fn test_device_identity_nul_in_name_rejected() {
let mut profile = make_isolated_profile();
profile.device.name = "te\0st".to_string();
let pid = ProfileId::new("test").unwrap();
let err = profile.validate(&pid).unwrap_err();
assert!(err.to_string().contains("NUL"));
}
#[test]
fn test_device_identity_nul_in_phys_rejected() {
let mut profile = make_isolated_profile();
profile.device.phys = "ph\0ys".to_string();
let pid = ProfileId::new("test").unwrap();
let err = profile.validate(&pid).unwrap_err();
assert!(err.to_string().contains("NUL"));
}
#[test]
fn test_device_identity_nul_in_uniq_rejected() {
let mut profile = make_isolated_profile();
profile.device.uniq = "un\0iq".to_string();
let pid = ProfileId::new("test").unwrap();
let err = profile.validate(&pid).unwrap_err();
assert!(err.to_string().contains("NUL"));
}
#[test]
fn test_device_identity_name_too_long_rejected() {
let mut profile = make_isolated_profile();
profile.device.name = "a".repeat(DEVICE_NAME_MAX + 1);
let pid = ProfileId::new("test").unwrap();
let err = profile.validate(&pid).unwrap_err();
assert!(err.to_string().contains("NUL terminator"));
}
#[test]
fn test_device_identity_phys_too_long_rejected() {
let mut profile = make_isolated_profile();
profile.device.phys = "a".repeat(DEVICE_PHYS_MAX + 1);
let pid = ProfileId::new("test").unwrap();
let err = profile.validate(&pid).unwrap_err();
assert!(err.to_string().contains("NUL terminator"));
}
#[test]
fn test_device_identity_uniq_too_long_rejected() {
let mut profile = make_isolated_profile();
profile.device.uniq = "a".repeat(DEVICE_UNIQ_MAX + 1);
let pid = ProfileId::new("test").unwrap();
let err = profile.validate(&pid).unwrap_err();
assert!(err.to_string().contains("NUL terminator"));
}
#[test]
fn test_device_identity_name_at_nul_boundary_rejected() {
let mut profile = make_isolated_profile();
profile.device.name = "a".repeat(UHID_NAME_MAX);
let pid = ProfileId::new("test").unwrap();
let err = profile.validate(&pid).unwrap_err();
assert!(err.to_string().contains("NUL terminator"));
}
#[test]
fn test_device_identity_phys_at_nul_boundary_rejected() {
let mut profile = make_isolated_profile();
profile.device.phys = "a".repeat(UHID_PHYS_MAX);
let pid = ProfileId::new("test").unwrap();
let err = profile.validate(&pid).unwrap_err();
assert!(err.to_string().contains("NUL terminator"));
}
#[test]
fn test_device_identity_uniq_at_nul_boundary_rejected() {
let mut profile = make_isolated_profile();
profile.device.uniq = "a".repeat(UHID_UNIQ_MAX);
let pid = ProfileId::new("test").unwrap();
let err = profile.validate(&pid).unwrap_err();
assert!(err.to_string().contains("NUL terminator"));
}
#[test]
fn test_device_identity_name_at_max_accepted() {
let mut profile = make_isolated_profile();
profile.device.name = "a".repeat(DEVICE_NAME_MAX);
let pid = ProfileId::new("test").unwrap();
assert!(profile.validate(&pid).is_ok());
}
#[test]
fn test_device_identity_phys_at_max_accepted() {
let mut profile = make_isolated_profile();
profile.device.phys = "a".repeat(DEVICE_PHYS_MAX);
let pid = ProfileId::new("test").unwrap();
assert!(profile.validate(&pid).is_ok());
}
#[test]
fn test_device_identity_uniq_at_max_accepted() {
let mut profile = make_isolated_profile();
profile.device.uniq = "a".repeat(DEVICE_UNIQ_MAX);
let pid = ProfileId::new("test").unwrap();
assert!(profile.validate(&pid).is_ok());
}
#[test]
fn test_device_identity_human_collision_rejected() {
let mut profile = make_isolated_profile();
profile.device.name = "virtual-fido".to_string();
profile.device.phys = "virtual-fido-001".to_string();
profile.device.vendor_id = 0x15d9;
profile.device.product_id = 0x0a37;
let pid = ProfileId::new("test").unwrap();
let err = profile.validate(&pid).unwrap_err();
assert!(err.to_string().contains("human authenticator"));
}
#[test]
fn test_device_identity_partial_human_match_allowed() {
let mut profile = make_isolated_profile();
profile.device.name = "virtual-fido".to_string();
profile.device.phys = "virtual-fido-001".to_string();
profile.device.vendor_id = 0x1234;
profile.device.product_id = 0x5678;
let pid = ProfileId::new("test").unwrap();
assert!(profile.validate(&pid).is_ok());
}
#[test]
fn test_enabled_requires_audit_path() {
let config = AgentConfig {
enabled: true,
profiles: BTreeMap::new(),
audit_path: None,
acknowledge_global_same_user: vec![],
acknowledge_same_user_registration: vec![],
};
let err = config.validate(None).unwrap_err();
assert!(err.to_string().contains("audit_path"));
}
#[test]
fn test_disabled_does_not_require_audit_path() {
let config = AgentConfig {
enabled: false,
profiles: BTreeMap::new(),
audit_path: None,
acknowledge_global_same_user: vec![],
acknowledge_same_user_registration: vec![],
};
assert!(config.validate(None).is_ok());
}
#[test]
fn test_path_overlap_labels_include_profile_id() {
let mut profiles = BTreeMap::new();
profiles.insert(
"myprofile".to_string(),
AgentProfileConfig {
max_operations: 64,
credential_selection: CredentialSelection::Single,
human_verification_prompt: HumanVerificationPrompt::Always,
mode: AgentMode::Isolated,
principal_user: "u1".to_string(),
rp_ids: vec!["a.com".to_string()],
require_uv: true,
credential_refs: None,
max_grant_ttl: None,
max_session_ttl: None,
storage: Some(AgentStorageConfig::Local {
path: PathBuf::from("/tmp/overlap/data"),
pin_path: PathBuf::from("/tmp/overlap/data/sub"),
}),
registration_allowed: false,
rules: vec![],
device: DeviceIdentity {
name: "overlap-test".to_string(),
phys: "p".to_string(),
uniq: "u".to_string(),
vendor_id: 1,
product_id: 1,
},
start_url: None,
browser_command: None,
browser_user: None,
browser_runtime_root: None,
browser_cdp_expose: None,
browser_cdp_port: None,
},
);
let config = AgentConfig {
enabled: true,
profiles,
audit_path: Some(PathBuf::from("/tmp/overlap-audit")),
acknowledge_global_same_user: vec![],
acknowledge_same_user_registration: vec![],
};
let err = config.validate(None).unwrap_err();
assert!(err.to_string().contains("myprofile.storage.credential"));
assert!(err.to_string().contains("myprofile.storage.pin"));
}
#[test]
fn test_btreemap_profiles_deterministic_order() {
let mut profiles = BTreeMap::new();
for name in ["zeta", "alpha", "mu", "beta"] {
profiles.insert(
name.to_string(),
AgentProfileConfig {
max_operations: 64,
credential_selection: CredentialSelection::Single,
human_verification_prompt: HumanVerificationPrompt::Always,
mode: AgentMode::Isolated,
principal_user: format!("u-{}", name),
rp_ids: vec!["a.com".to_string()],
require_uv: true,
credential_refs: None,
max_grant_ttl: None,
max_session_ttl: None,
storage: Some(AgentStorageConfig::Local {
path: PathBuf::from(format!("/tmp/{}", name)),
pin_path: PathBuf::from(format!("/tmp/{}-pin", name)),
}),
registration_allowed: false,
rules: vec![],
device: DeviceIdentity {
name: format!("dev-{}", name),
phys: format!("phys-{}", name),
uniq: format!("uniq-{}", name),
vendor_id: 1,
product_id: 1,
},
start_url: None,
browser_command: None,
browser_user: None,
browser_runtime_root: None,
browser_cdp_expose: None,
browser_cdp_port: None,
},
);
}
let keys: Vec<String> = profiles.keys().cloned().collect();
assert_eq!(keys, vec!["alpha", "beta", "mu", "zeta"]);
}
#[test]
fn test_credential_ref_serde_hex_roundtrip() {
let cred_ref = CredentialRef::with_default_domain(b"test-credential");
let json = serde_json::to_string(&cred_ref).unwrap();
assert_eq!(json.len(), 66);
assert!(json.starts_with('"'));
assert!(json.ends_with('"'));
let hex_str = &json[1..65];
assert!(hex_str.chars().all(|c| c.is_ascii_hexdigit()));
let parsed: CredentialRef = serde_json::from_str(&json).unwrap();
assert_eq!(cred_ref, parsed);
}
#[test]
fn test_credential_ref_serde_rejects_short_hex() {
let json = r#""abcdef01""#;
let result: std::result::Result<CredentialRef, _> = serde_json::from_str(json);
assert!(result.is_err());
}
#[test]
fn test_credential_ref_serde_rejects_non_hex() {
let json = format!(r#""{}""#, "g".repeat(64));
let result: std::result::Result<CredentialRef, _> = serde_json::from_str(&json);
assert!(result.is_err());
}
#[test]
fn test_malformed_agents_section_fails_load() {
use clap::Parser;
let dir = tempfile::tempdir().unwrap();
let config_path = dir.path().join("config.toml");
std::fs::write(
&config_path,
r#"
backend_type = "local"
[agents]
enabled = "not_a_bool"
"#,
)
.unwrap();
let mut args =
crate::config::Args::try_parse_from(["passless", "-c", config_path.to_str().unwrap()])
.unwrap();
let result = crate::config::AppConfig::load(&mut args);
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("[agents]"));
}
#[test]
fn test_no_agents_section_backward_compatible() {
use clap::Parser;
let dir = tempfile::tempdir().unwrap();
let config_path = dir.path().join("config.toml");
std::fs::write(
&config_path,
r#"
backend_type = "local"
"#,
)
.unwrap();
let mut args =
crate::config::Args::try_parse_from(["passless", "-c", config_path.to_str().unwrap()])
.unwrap();
let result = crate::config::AppConfig::load(&mut args);
assert!(result.is_ok());
}
#[test]
fn test_malformed_toml_fails_load() {
use clap::Parser;
let dir = tempfile::tempdir().unwrap();
let config_path = dir.path().join("config.toml");
std::fs::write(&config_path, "this is not valid toml {{{").unwrap();
let mut args =
crate::config::Args::try_parse_from(["passless", "-c", config_path.to_str().unwrap()])
.unwrap();
let result = crate::config::AppConfig::load(&mut args);
assert!(result.is_err());
}
#[test]
fn test_symlink_overlap_between_credential_store_and_pin_store() {
let dir = tempfile::tempdir().unwrap();
let real_dir = dir.path().join("real");
std::fs::create_dir(&real_dir).unwrap();
let symlink_dir = dir.path().join("link");
std::os::unix::fs::symlink(&real_dir, &symlink_dir).unwrap();
let mut profiles = BTreeMap::new();
profiles.insert(
"a".to_string(),
AgentProfileConfig {
max_operations: 64,
credential_selection: CredentialSelection::Single,
human_verification_prompt: HumanVerificationPrompt::Always,
mode: AgentMode::Isolated,
principal_user: "u1".to_string(),
rp_ids: vec!["a.com".to_string()],
require_uv: true,
credential_refs: None,
max_grant_ttl: None,
max_session_ttl: None,
storage: Some(AgentStorageConfig::Local {
path: real_dir.clone(),
pin_path: symlink_dir.clone(),
}),
registration_allowed: false,
rules: vec![],
device: DeviceIdentity {
name: "a".to_string(),
phys: "a".to_string(),
uniq: "a".to_string(),
vendor_id: 1,
product_id: 1,
},
start_url: None,
browser_command: None,
browser_user: None,
browser_runtime_root: None,
browser_cdp_expose: None,
browser_cdp_port: None,
},
);
let config = AgentConfig {
enabled: true,
profiles,
audit_path: Some(dir.path().join("audit")),
acknowledge_global_same_user: vec![],
acknowledge_same_user_registration: vec![],
};
let err = config.validate(None).unwrap_err();
assert!(err.to_string().contains("overlap"));
}
#[test]
fn test_symlink_replacement_credential_store_via_audit_path() {
let dir = tempfile::tempdir().unwrap();
let real_audit = dir.path().join("audit_real");
std::fs::create_dir(&real_audit).unwrap();
let audit_link = dir.path().join("audit_link");
std::os::unix::fs::symlink(&real_audit, &audit_link).unwrap();
let cred_store = dir.path().join("creds");
std::fs::create_dir(&cred_store).unwrap();
let cred_link = dir.path().join("creds_link");
std::os::unix::fs::symlink(&cred_store, &cred_link).unwrap();
let nested = real_audit.join("sub");
std::fs::create_dir(&nested).unwrap();
let mut profiles = BTreeMap::new();
profiles.insert(
"a".to_string(),
AgentProfileConfig {
max_operations: 64,
credential_selection: CredentialSelection::Single,
human_verification_prompt: HumanVerificationPrompt::Always,
mode: AgentMode::Isolated,
principal_user: "u1".to_string(),
rp_ids: vec!["a.com".to_string()],
require_uv: true,
credential_refs: None,
max_grant_ttl: None,
max_session_ttl: None,
storage: Some(AgentStorageConfig::Local {
path: nested.clone(),
pin_path: dir.path().join("pin"),
}),
registration_allowed: false,
rules: vec![],
device: DeviceIdentity {
name: "a".to_string(),
phys: "a".to_string(),
uniq: "a".to_string(),
vendor_id: 1,
product_id: 1,
},
start_url: None,
browser_command: None,
browser_user: None,
browser_runtime_root: None,
browser_cdp_expose: None,
browser_cdp_port: None,
},
);
let config = AgentConfig {
enabled: true,
profiles,
audit_path: Some(audit_link),
acknowledge_global_same_user: vec![],
acknowledge_same_user_registration: vec![],
};
let err = config.validate(None).unwrap_err();
assert!(err.to_string().contains("overlap"));
}
#[test]
fn test_symlink_overlap_with_human_state_path() {
let dir = tempfile::tempdir().unwrap();
let human_real = dir.path().join("human_real");
std::fs::create_dir(&human_real).unwrap();
let human_link = dir.path().join("human_link");
std::os::unix::fs::symlink(&human_real, &human_link).unwrap();
let mut profiles = BTreeMap::new();
profiles.insert(
"a".to_string(),
AgentProfileConfig {
max_operations: 64,
credential_selection: CredentialSelection::Single,
human_verification_prompt: HumanVerificationPrompt::Always,
mode: AgentMode::Isolated,
principal_user: "u1".to_string(),
rp_ids: vec!["a.com".to_string()],
require_uv: true,
credential_refs: None,
max_grant_ttl: None,
max_session_ttl: None,
storage: Some(AgentStorageConfig::Local {
path: human_real.join("creds"),
pin_path: dir.path().join("pin"),
}),
registration_allowed: false,
rules: vec![],
device: DeviceIdentity {
name: "a".to_string(),
phys: "a".to_string(),
uniq: "a".to_string(),
vendor_id: 1,
product_id: 1,
},
start_url: None,
browser_command: None,
browser_user: None,
browser_runtime_root: None,
browser_cdp_expose: None,
browser_cdp_port: None,
},
);
let config = AgentConfig {
enabled: true,
profiles,
audit_path: Some(dir.path().join("audit")),
acknowledge_global_same_user: vec![],
acknowledge_same_user_registration: vec![],
};
let err = config.validate(Some(human_link.as_path())).unwrap_err();
assert!(err.to_string().contains("overlaps with human"));
}
#[test]
fn test_migration_old_credential_store_field_rejected() {
let toml_str = r#"
enabled = true
[profiles.bot]
mode = "isolated"
principal_user = "u"
rp_ids = ["example.com"]
require_uv = true
credential_store = "/tmp/creds"
pin_store = "/tmp/pin"
registration_allowed = true
[profiles.bot.device]
name = "n"
phys = "p"
uniq = "u"
vendor_id = 1
product_id = 2
"#;
let result: std::result::Result<AgentConfig, _> = toml::from_str(toml_str);
assert!(result.is_err());
let err = result.unwrap_err().to_string();
assert!(
err.contains("credential_store") || err.contains("unknown field"),
"expected migration error about old field, got: {}",
err
);
}
#[test]
fn test_migration_old_pin_store_field_rejected() {
let toml_str = r#"
enabled = true
[profiles.bot]
mode = "isolated"
principal_user = "u"
rp_ids = ["example.com"]
require_uv = true
pin_store = "/tmp/pin"
[profiles.bot.storage.local]
path = "/tmp/creds"
pin_path = "/tmp/pin2"
[profiles.bot.device]
name = "n"
phys = "p"
uniq = "u"
vendor_id = 1
product_id = 2
"#;
let result: std::result::Result<AgentConfig, _> = toml::from_str(toml_str);
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("pin_store"));
}
#[test]
fn test_snapshot_toml_local_storage_roundtrip() {
let toml_str = r#"
enabled = true
[profiles.bot]
mode = "isolated"
principal_user = "bot-user"
rp_ids = ["example.com"]
require_uv = true
registration_allowed = true
[profiles.bot.device]
name = "agent-bot"
phys = "bot-phys"
uniq = "bot-uniq"
vendor_id = 4660
product_id = 22137
[profiles.bot.storage.local]
path = "/var/lib/passless-agent/bot/credentials"
pin_path = "/var/lib/passless-agent/bot/pin"
"#;
let config: AgentConfig = toml::from_str(toml_str).unwrap();
let profile = config.profiles.get("bot").unwrap();
assert_eq!(profile.mode, AgentMode::Isolated);
match &profile.storage {
Some(AgentStorageConfig::Local { path, pin_path }) => {
assert_eq!(
path,
&PathBuf::from("/var/lib/passless-agent/bot/credentials")
);
assert_eq!(pin_path, &PathBuf::from("/var/lib/passless-agent/bot/pin"));
}
other => panic!("expected Local storage, got: {:?}", other),
}
}
#[test]
fn test_snapshot_toml_pass_storage_roundtrip() {
let toml_str = r#"
enabled = true
[profiles.ci]
mode = "isolated"
principal_user = "ci-user"
rp_ids = ["ci.example.com"]
require_uv = true
registration_allowed = false
[profiles.ci.device]
name = "agent-ci"
phys = "ci-phys"
uniq = "ci-uniq"
vendor_id = 4660
product_id = 22138
[profiles.ci.storage.pass]
store_path = "/home/user/.password-store"
path = "fido2/ci"
gpg_backend = "gpgme"
pin_path = "pin"
"#;
let config: AgentConfig = toml::from_str(toml_str).unwrap();
let profile = config.profiles.get("ci").unwrap();
match &profile.storage {
Some(AgentStorageConfig::Pass {
store_path,
path,
gpg_backend,
pin_path,
}) => {
assert_eq!(store_path, &PathBuf::from("/home/user/.password-store"));
assert_eq!(path, "fido2/ci");
assert_eq!(gpg_backend, "gpgme");
assert_eq!(pin_path, &PathBuf::from("pin"));
}
other => panic!("expected Pass storage, got: {:?}", other),
}
}
#[test]
fn test_snapshot_toml_pass_storage_default_gpg_backend() {
let toml_str = r#"
enabled = true
[profiles.ci]
mode = "isolated"
principal_user = "ci-user"
rp_ids = ["ci.example.com"]
require_uv = true
[profiles.ci.device]
name = "agent-ci"
phys = "ci-phys"
uniq = "ci-uniq"
vendor_id = 4660
product_id = 22138
[profiles.ci.storage.pass]
store_path = "/home/user/.password-store"
path = "fido2"
pin_path = "pin"
"#;
let config: AgentConfig = toml::from_str(toml_str).unwrap();
let profile = config.profiles.get("ci").unwrap();
match &profile.storage {
Some(AgentStorageConfig::Pass { gpg_backend, .. }) => {
assert_eq!(gpg_backend, "gnupg-bin");
}
other => panic!("expected Pass storage, got: {:?}", other),
}
}
#[cfg(feature = "tpm")]
#[test]
fn test_snapshot_toml_tpm_storage_roundtrip() {
let toml_str = r#"
enabled = true
[profiles.secure]
mode = "isolated"
principal_user = "secure-user"
rp_ids = ["secure.example.com"]
require_uv = true
[profiles.secure.device]
name = "agent-secure"
phys = "secure-phys"
uniq = "secure-uniq"
vendor_id = 4660
product_id = 22139
[profiles.secure.storage.tpm]
path = "/var/lib/passless-agent/secure/tpm"
tcti = "swtpm:path=/tmp/swtpm-sock"
pin_path = "/var/lib/passless-agent/secure/pin"
"#;
let config: AgentConfig = toml::from_str(toml_str).unwrap();
let profile = config.profiles.get("secure").unwrap();
match &profile.storage {
Some(AgentStorageConfig::Tpm {
path,
tcti,
pin_path,
portable,
}) => {
assert_eq!(path, &PathBuf::from("/var/lib/passless-agent/secure/tpm"));
assert_eq!(tcti, "swtpm:path=/tmp/swtpm-sock");
assert_eq!(
pin_path,
&PathBuf::from("/var/lib/passless-agent/secure/pin")
);
assert!(!portable);
}
other => panic!("expected Tpm storage, got: {:?}", other),
}
}
#[cfg(feature = "tpm")]
#[test]
fn test_snapshot_toml_tpm_storage_default_tcti() {
let toml_str = r#"
enabled = true
[profiles.secure]
mode = "isolated"
principal_user = "secure-user"
rp_ids = ["secure.example.com"]
require_uv = true
[profiles.secure.device]
name = "agent-secure"
phys = "secure-phys"
uniq = "secure-uniq"
vendor_id = 4660
product_id = 22139
[profiles.secure.storage.tpm]
path = "/var/lib/passless-agent/secure/tpm"
pin_path = "/var/lib/passless-agent/secure/pin"
"#;
let config: AgentConfig = toml::from_str(toml_str).unwrap();
let profile = config.profiles.get("secure").unwrap();
match &profile.storage {
Some(AgentStorageConfig::Tpm { tcti, .. }) => {
assert_eq!(tcti, "device:/dev/tpmrm0");
}
other => panic!("expected Tpm storage, got: {:?}", other),
}
}
#[test]
fn test_storage_backend_config_conversion_local() {
let storage = AgentStorageConfig::Local {
path: PathBuf::from("/tmp/creds"),
pin_path: PathBuf::from("/tmp/pin"),
};
let backend = storage.to_backend_config();
match backend {
crate::config::BackendConfig::Local { path } => {
assert_eq!(path, "/tmp/creds");
}
_ => panic!("expected Local backend"),
}
}
#[test]
fn test_storage_backend_config_conversion_pass() {
let storage = AgentStorageConfig::Pass {
store_path: PathBuf::from("/home/user/.password-store"),
path: "fido2".to_string(),
gpg_backend: "gnupg-bin".to_string(),
pin_path: PathBuf::from("pin"),
};
let backend = storage.to_backend_config();
match backend {
crate::config::BackendConfig::Pass {
store_path,
path,
gpg_backend,
} => {
assert_eq!(store_path, "/home/user/.password-store");
assert_eq!(path, "fido2");
assert_eq!(gpg_backend, "gnupg-bin");
}
_ => panic!("expected Pass backend"),
}
}
#[test]
fn test_storage_all_paths_returns_two_entries() {
let storage = AgentStorageConfig::Local {
path: PathBuf::from("/tmp/creds"),
pin_path: PathBuf::from("/tmp/pin"),
};
let paths = storage.all_paths();
assert_eq!(paths.len(), 2);
assert_eq!(paths[0].0, "credential");
assert_eq!(paths[1].0, "pin");
}
#[test]
fn test_storage_pin_paths_never_shared_across_profiles() {
let mut profiles = BTreeMap::new();
let shared_pin = PathBuf::from("/tmp/shared-pin");
profiles.insert(
"a".to_string(),
AgentProfileConfig {
max_operations: 64,
credential_selection: CredentialSelection::Single,
human_verification_prompt: HumanVerificationPrompt::Always,
mode: AgentMode::Isolated,
principal_user: "u1".to_string(),
rp_ids: vec!["a.com".to_string()],
require_uv: true,
credential_refs: None,
max_grant_ttl: None,
max_session_ttl: None,
storage: Some(AgentStorageConfig::Local {
path: PathBuf::from("/tmp/a/creds"),
pin_path: shared_pin.clone(),
}),
registration_allowed: false,
rules: vec![],
device: DeviceIdentity {
name: "a".to_string(),
phys: "a".to_string(),
uniq: "a".to_string(),
vendor_id: 1,
product_id: 1,
},
start_url: None,
browser_command: None,
browser_user: None,
browser_runtime_root: None,
browser_cdp_expose: None,
browser_cdp_port: None,
},
);
profiles.insert(
"b".to_string(),
AgentProfileConfig {
max_operations: 64,
credential_selection: CredentialSelection::Single,
human_verification_prompt: HumanVerificationPrompt::Always,
mode: AgentMode::Isolated,
principal_user: "u2".to_string(),
rp_ids: vec!["b.com".to_string()],
require_uv: true,
credential_refs: None,
max_grant_ttl: None,
max_session_ttl: None,
storage: Some(AgentStorageConfig::Local {
path: PathBuf::from("/tmp/b/creds"),
pin_path: shared_pin,
}),
registration_allowed: false,
rules: vec![],
device: DeviceIdentity {
name: "b".to_string(),
phys: "b".to_string(),
uniq: "b".to_string(),
vendor_id: 2,
product_id: 2,
},
start_url: None,
browser_command: None,
browser_user: None,
browser_runtime_root: None,
browser_cdp_expose: None,
browser_cdp_port: None,
},
);
let config = AgentConfig {
enabled: true,
profiles,
audit_path: Some(PathBuf::from("/tmp/audit")),
acknowledge_global_same_user: vec![],
acknowledge_same_user_registration: vec![],
};
let err = config.validate(None).unwrap_err();
assert!(err.to_string().contains("overlap"));
}
#[test]
fn test_storage_display() {
let local = AgentStorageConfig::Local {
path: PathBuf::from("/tmp/c"),
pin_path: PathBuf::from("/tmp/p"),
};
assert_eq!(format!("{}", local), "local");
let pass = AgentStorageConfig::Pass {
store_path: PathBuf::from("/tmp/s"),
path: "fido2".to_string(),
gpg_backend: "gnupg-bin".to_string(),
pin_path: PathBuf::from("pin"),
};
assert_eq!(format!("{}", pass), "pass");
}
#[test]
fn test_storage_serde_rejects_unknown_variant() {
let json = r#"{"unknown": {"path": "/tmp/c", "pin_path": "/tmp/p"}}"#;
let result: std::result::Result<AgentStorageConfig, _> = serde_json::from_str(json);
assert!(result.is_err());
}
#[test]
fn test_storage_serde_rejects_missing_pin_path() {
let json = r#"{"local": {"path": "/tmp/c"}}"#;
let result: std::result::Result<AgentStorageConfig, _> = serde_json::from_str(json);
assert!(result.is_err());
}
#[test]
fn test_storage_cross_profile_credential_overlap_rejected() {
let mut profiles = BTreeMap::new();
let shared_cred = PathBuf::from("/tmp/shared-creds");
profiles.insert(
"a".to_string(),
AgentProfileConfig {
max_operations: 64,
credential_selection: CredentialSelection::Single,
human_verification_prompt: HumanVerificationPrompt::Always,
mode: AgentMode::Isolated,
principal_user: "u1".to_string(),
rp_ids: vec!["a.com".to_string()],
require_uv: true,
credential_refs: None,
max_grant_ttl: None,
max_session_ttl: None,
storage: Some(AgentStorageConfig::Local {
path: shared_cred.clone(),
pin_path: PathBuf::from("/tmp/a/pin"),
}),
registration_allowed: false,
rules: vec![],
device: DeviceIdentity {
name: "a".to_string(),
phys: "a".to_string(),
uniq: "a".to_string(),
vendor_id: 1,
product_id: 1,
},
start_url: None,
browser_command: None,
browser_user: None,
browser_runtime_root: None,
browser_cdp_expose: None,
browser_cdp_port: None,
},
);
profiles.insert(
"b".to_string(),
AgentProfileConfig {
max_operations: 64,
credential_selection: CredentialSelection::Single,
human_verification_prompt: HumanVerificationPrompt::Always,
mode: AgentMode::Isolated,
principal_user: "u2".to_string(),
rp_ids: vec!["b.com".to_string()],
require_uv: true,
credential_refs: None,
max_grant_ttl: None,
max_session_ttl: None,
storage: Some(AgentStorageConfig::Local {
path: shared_cred,
pin_path: PathBuf::from("/tmp/b/pin"),
}),
registration_allowed: false,
rules: vec![],
device: DeviceIdentity {
name: "b".to_string(),
phys: "b".to_string(),
uniq: "b".to_string(),
vendor_id: 2,
product_id: 2,
},
start_url: None,
browser_command: None,
browser_user: None,
browser_runtime_root: None,
browser_cdp_expose: None,
browser_cdp_port: None,
},
);
let config = AgentConfig {
enabled: true,
profiles,
audit_path: Some(PathBuf::from("/tmp/audit")),
acknowledge_global_same_user: vec![],
acknowledge_same_user_registration: vec![],
};
let err = config.validate(None).unwrap_err();
assert!(err.to_string().contains("overlap"));
}
#[test]
fn test_storage_audit_overlap_rejected() {
let dir = tempfile::tempdir().unwrap();
let audit = dir.path().join("audit");
let mut profiles = BTreeMap::new();
profiles.insert(
"a".to_string(),
AgentProfileConfig {
max_operations: 64,
credential_selection: CredentialSelection::Single,
human_verification_prompt: HumanVerificationPrompt::Always,
mode: AgentMode::Isolated,
principal_user: "u1".to_string(),
rp_ids: vec!["a.com".to_string()],
require_uv: true,
credential_refs: None,
max_grant_ttl: None,
max_session_ttl: None,
storage: Some(AgentStorageConfig::Local {
path: dir.path().join("creds"),
pin_path: audit.clone(),
}),
registration_allowed: false,
rules: vec![],
device: DeviceIdentity {
name: "a".to_string(),
phys: "a".to_string(),
uniq: "a".to_string(),
vendor_id: 1,
product_id: 1,
},
start_url: None,
browser_command: None,
browser_user: None,
browser_runtime_root: None,
browser_cdp_expose: None,
browser_cdp_port: None,
},
);
let config = AgentConfig {
enabled: true,
profiles,
audit_path: Some(audit),
acknowledge_global_same_user: vec![],
acknowledge_same_user_registration: vec![],
};
let err = config.validate(None).unwrap_err();
assert!(err.to_string().contains("overlap"));
}
#[test]
fn test_storage_pass_credential_path_derives_from_store_and_subpath() {
let dir = tempfile::tempdir().unwrap();
let store = dir.path().join("store");
std::fs::create_dir_all(&store).unwrap();
let storage = AgentStorageConfig::Pass {
store_path: store.clone(),
path: "fido2".to_string(),
gpg_backend: "gnupg-bin".to_string(),
pin_path: PathBuf::from("pin"),
};
let cred_path = storage.credential_state_path();
let expected = crate::config::BackendConfig::canonicalize_path(&store.join("fido2"));
assert_eq!(cred_path, expected);
}
#[test]
fn test_storage_pin_path_is_independent() {
let storage = AgentStorageConfig::Local {
path: PathBuf::from("/tmp/creds"),
pin_path: PathBuf::from("/tmp/pin"),
};
let cred = storage.credential_state_path();
let pin = storage.pin_state_path();
assert_ne!(cred, pin);
}
#[test]
fn test_isolated_mode_rejects_browser_user() {
let mut profile = make_isolated_profile();
profile.browser_user = Some("browser-user".to_string());
let pid = ProfileId::new("test").unwrap();
let err = profile.validate(&pid).unwrap_err();
assert!(err.to_string().contains("browser_user"));
}
#[test]
fn test_isolated_mode_rejects_browser_runtime_root() {
let mut profile = make_isolated_profile();
profile.browser_runtime_root = Some(PathBuf::from("/var/run/browser"));
let pid = ProfileId::new("test").unwrap();
let err = profile.validate(&pid).unwrap_err();
assert!(err.to_string().contains("browser_runtime_root"));
}
#[cfg(feature = "tpm")]
#[test]
fn test_storage_backend_config_conversion_tpm_portable_true() {
let storage = AgentStorageConfig::Tpm {
path: PathBuf::from("/tmp/tpm-creds"),
tcti: "swtpm:host=127.0.0.1,port=2321".to_string(),
pin_path: PathBuf::from("/tmp/tpm-pin"),
portable: true,
};
let backend = storage.to_backend_config();
match backend {
crate::config::BackendConfig::Tpm {
path,
tcti,
portable,
} => {
assert_eq!(path, "/tmp/tpm-creds");
assert_eq!(tcti, "swtpm:host=127.0.0.1,port=2321");
assert!(portable);
}
_ => panic!("expected Tpm backend"),
}
}
#[cfg(feature = "tpm")]
#[test]
fn test_storage_backend_config_conversion_tpm_portable_false() {
let storage = AgentStorageConfig::Tpm {
path: PathBuf::from("/tmp/tpm-creds"),
tcti: "device:/dev/tpmrm0".to_string(),
pin_path: PathBuf::from("/tmp/tpm-pin"),
portable: false,
};
let backend = storage.to_backend_config();
match backend {
crate::config::BackendConfig::Tpm {
path,
tcti,
portable,
} => {
assert_eq!(path, "/tmp/tpm-creds");
assert_eq!(tcti, "device:/dev/tpmrm0");
assert!(!portable);
}
_ => panic!("expected Tpm backend"),
}
}
#[cfg(feature = "tpm")]
#[test]
fn test_snapshot_toml_tpm_storage_portable_true() {
let toml_str = r#"
enabled = true
[profiles.secure]
mode = "isolated"
principal_user = "secure-user"
rp_ids = ["secure.example.com"]
require_uv = true
[profiles.secure.device]
name = "agent-secure"
phys = "secure-phys"
uniq = "secure-uniq"
vendor_id = 4660
product_id = 22139
[profiles.secure.storage.tpm]
path = "/var/lib/passless-agent/secure/tpm"
tcti = "swtpm:path=/tmp/swtpm-sock"
pin_path = "/var/lib/passless-agent/secure/pin"
portable = true
"#;
let config: AgentConfig = toml::from_str(toml_str).unwrap();
let profile = config.profiles.get("secure").unwrap();
match &profile.storage {
Some(AgentStorageConfig::Tpm {
path,
tcti,
pin_path,
portable,
}) => {
assert_eq!(path, &PathBuf::from("/var/lib/passless-agent/secure/tpm"));
assert_eq!(tcti, "swtpm:path=/tmp/swtpm-sock");
assert_eq!(
pin_path,
&PathBuf::from("/var/lib/passless-agent/secure/pin")
);
assert!(*portable);
}
other => panic!("expected Tpm storage, got: {:?}", other),
}
}
#[test]
fn global_same_user_requires_operator_acknowledgement() {
let raw = r#"
enabled = true
audit_path = "/tmp/passless-agent-audit"
[profiles.coding]
mode = "same-user"
principal_user = "alice"
[[profiles.coding.rules]]
rp_id = "*"
authenticate = "autonomous"
register = "deny"
"#;
let cfg: AgentConfig = toml::from_str(raw).unwrap();
let err = cfg.validate(None).unwrap_err().to_string();
assert!(err.contains("acknowledge_global_same_user"));
}
#[test]
fn global_same_user_accepts_operator_acknowledgement() {
let raw = r#"
enabled = true
audit_path = "/tmp/passless-agent-audit"
acknowledge_global_same_user = ["coding"]
[profiles.coding]
mode = "same-user"
principal_user = "alice"
[[profiles.coding.rules]]
rp_id = "*"
authenticate = "autonomous"
register = "deny"
"#;
let cfg: AgentConfig = toml::from_str(raw).unwrap();
cfg.validate(None).unwrap();
}
#[test]
fn same_user_registration_requires_operator_acknowledgement() {
let raw = r#"
enabled = true
audit_path = "/tmp/passless-agent-audit"
[profiles.coding]
mode = "same-user"
principal_user = "alice"
[[profiles.coding.rules]]
rp_id = "example.com"
authenticate = "deny"
register = "supervised"
"#;
let cfg: AgentConfig = toml::from_str(raw).unwrap();
let err = cfg.validate(None).unwrap_err().to_string();
assert!(err.contains("acknowledge_same_user_registration"));
}
#[test]
fn dangerous_acknowledgement_rejects_unknown_profile() {
let raw = r#"
enabled = true
audit_path = "/tmp/passless-agent-audit"
acknowledge_global_same_user = ["missing"]
"#;
let cfg: AgentConfig = toml::from_str(raw).unwrap();
let err = cfg.validate(None).unwrap_err().to_string();
assert!(err.contains("unknown profile 'missing'"));
}
#[test]
fn credential_selection_for_rp_uses_exact_rule_override() {
let mut profile = make_isolated_profile();
profile.credential_selection = CredentialSelection::Single;
profile.rp_ids.clear();
profile.registration_allowed = false;
profile.require_uv = false;
profile.rules = vec![AgentRpRule {
rp_id: "github.com".to_string(),
register: AgentCeremonyPolicy::deny(),
authenticate: AgentCeremonyPolicy::autonomous(),
credential_selection: Some(CredentialSelection::Newest),
}];
assert_eq!(
profile.credential_selection_for_rp("github.com"),
CredentialSelection::Newest
);
}
#[test]
fn credential_selection_for_rp_uses_wildcard_fallback() {
let mut profile = make_isolated_profile();
profile.credential_selection = CredentialSelection::Single;
profile.rp_ids.clear();
profile.registration_allowed = false;
profile.require_uv = false;
profile.mode = AgentMode::SameUser;
profile.rules = vec![AgentRpRule {
rp_id: "*".to_string(),
register: AgentCeremonyPolicy::deny(),
authenticate: AgentCeremonyPolicy::autonomous(),
credential_selection: Some(CredentialSelection::FirstMatching),
}];
assert_eq!(
profile.credential_selection_for_rp("any-rp.com"),
CredentialSelection::FirstMatching
);
}
#[test]
fn credential_selection_for_rp_exact_beats_wildcard() {
let mut profile = make_isolated_profile();
profile.credential_selection = CredentialSelection::Single;
profile.rp_ids.clear();
profile.registration_allowed = false;
profile.require_uv = false;
profile.mode = AgentMode::SameUser;
profile.rules = vec![
AgentRpRule {
rp_id: "*".to_string(),
register: AgentCeremonyPolicy::deny(),
authenticate: AgentCeremonyPolicy::autonomous(),
credential_selection: Some(CredentialSelection::FirstMatching),
},
AgentRpRule {
rp_id: "github.com".to_string(),
register: AgentCeremonyPolicy::deny(),
authenticate: AgentCeremonyPolicy::autonomous(),
credential_selection: Some(CredentialSelection::Newest),
},
];
assert_eq!(
profile.credential_selection_for_rp("github.com"),
CredentialSelection::Newest
);
assert_eq!(
profile.credential_selection_for_rp("other.com"),
CredentialSelection::FirstMatching
);
}
#[test]
fn credential_selection_for_rp_falls_back_to_profile_default() {
let mut profile = make_isolated_profile();
profile.credential_selection = CredentialSelection::Newest;
profile.rp_ids.clear();
profile.registration_allowed = false;
profile.require_uv = false;
profile.rules = vec![AgentRpRule {
rp_id: "github.com".to_string(),
register: AgentCeremonyPolicy::deny(),
authenticate: AgentCeremonyPolicy::autonomous(),
credential_selection: None,
}];
assert_eq!(
profile.credential_selection_for_rp("github.com"),
CredentialSelection::Newest
);
}
#[test]
fn rule_credential_ref_must_be_in_credential_refs() {
let cred_ref = CredentialRef::with_default_domain(b"test-cred");
let mut profile = make_isolated_profile();
profile.rp_ids.clear();
profile.registration_allowed = false;
profile.require_uv = false;
profile.credential_refs = Some(vec![cred_ref.clone()]);
profile.rules = vec![AgentRpRule {
rp_id: "github.com".to_string(),
register: AgentCeremonyPolicy::deny(),
authenticate: AgentCeremonyPolicy::autonomous(),
credential_selection: Some(CredentialSelection::Credential(cred_ref)),
}];
assert!(profile.validate(&ProfileId::new("test").unwrap()).is_ok());
}
#[test]
fn rule_credential_ref_rejected_when_not_in_credential_refs() {
let cred_ref = CredentialRef::with_default_domain(b"test-cred");
let other_ref = CredentialRef::with_default_domain(b"other-cred");
let mut profile = make_isolated_profile();
profile.rp_ids.clear();
profile.registration_allowed = false;
profile.require_uv = false;
profile.credential_refs = Some(vec![cred_ref]);
profile.rules = vec![AgentRpRule {
rp_id: "github.com".to_string(),
register: AgentCeremonyPolicy::deny(),
authenticate: AgentCeremonyPolicy::autonomous(),
credential_selection: Some(CredentialSelection::Credential(other_ref)),
}];
let err = profile
.validate(&ProfileId::new("test").unwrap())
.unwrap_err();
assert!(
err.to_string()
.contains("must be included in credential_refs")
);
}
#[test]
fn wildcard_rule_cannot_use_credential_ref() {
let cred_ref = CredentialRef::with_default_domain(b"test-cred");
let mut profile = make_isolated_profile();
profile.rp_ids.clear();
profile.registration_allowed = false;
profile.require_uv = false;
profile.mode = AgentMode::SameUser;
profile.rules = vec![AgentRpRule {
rp_id: "*".to_string(),
register: AgentCeremonyPolicy::deny(),
authenticate: AgentCeremonyPolicy::autonomous(),
credential_selection: Some(CredentialSelection::Credential(cred_ref)),
}];
let err = profile
.validate(&ProfileId::new("test").unwrap())
.unwrap_err();
assert!(
err.to_string()
.contains("cannot select one RP-specific credential reference")
);
}
}