Skip to content
7 changes: 7 additions & 0 deletions clippy.toml
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,13 @@ disallowed-methods = [
{ path = "std::time::SystemTime::now", reason = "inject ClockProvider instead of calling SystemTime::now() directly", allow-invalid = true },
{ path = "std::env::var", reason = "use EnvironmentConfig abstraction instead of reading env vars directly", allow-invalid = true },
{ path = "uuid::Uuid::new_v4", reason = "Use UuidProvider::new_id() instead. Inject SystemUuidProvider in production and DeterministicUuidProvider in tests." },

# === DID/newtype construction: prefer parse() for external input ===
{ path = "auths_verifier::types::IdentityDID::new_unchecked", reason = "Use IdentityDID::parse() for external input. Use #[allow(clippy::disallowed_methods)] with INVARIANT comment for proven-safe paths.", allow-invalid = true },
{ path = "auths_verifier::types::DeviceDID::new_unchecked", reason = "Use DeviceDID::parse() for external input. Use #[allow(clippy::disallowed_methods)] with INVARIANT comment for proven-safe paths.", allow-invalid = true },
{ path = "auths_verifier::types::CanonicalDid::new_unchecked", reason = "Use CanonicalDid::parse() for external input. Use #[allow(clippy::disallowed_methods)] with INVARIANT comment for proven-safe paths.", allow-invalid = true },
{ path = "auths_verifier::core::CommitOid::new_unchecked", reason = "Use CommitOid::parse() for external input. Use #[allow(clippy::disallowed_methods)] with INVARIANT comment for proven-safe paths.", allow-invalid = true },
{ path = "auths_verifier::core::PublicKeyHex::new_unchecked", reason = "Use PublicKeyHex::parse() for external input. Use #[allow(clippy::disallowed_methods)] with INVARIANT comment for proven-safe paths.", allow-invalid = true },
]
allow-unwrap-in-tests = true
allow-expect-in-tests = true
9 changes: 6 additions & 3 deletions crates/auths-cli/clippy.toml
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,10 @@ disallowed-methods = [
{ path = "std::env::var", reason = "use EnvironmentConfig abstraction instead of reading env vars directly", allow-invalid = true },
{ path = "uuid::Uuid::new_v4", reason = "Use UuidProvider::new_id() instead. Inject SystemUuidProvider in production and DeterministicUuidProvider in tests." },

# === DID construction: prefer parse() for user input ===
{ path = "auths_verifier::types::DeviceDID::new_unchecked", reason = "CLI should use DeviceDID::parse() for user input. Use #[allow] with INVARIANT comment for internally-derived values.", allow-invalid = true },
{ path = "auths_verifier::types::IdentityDID::new_unchecked", reason = "CLI should use IdentityDID::parse() for user input. Use #[allow] with INVARIANT comment for internally-derived values.", allow-invalid = true },
# === DID/newtype construction: prefer parse() for external input ===
{ path = "auths_verifier::types::IdentityDID::new_unchecked", reason = "Use IdentityDID::parse() for external input. Use #[allow(clippy::disallowed_methods)] with INVARIANT comment for proven-safe paths.", allow-invalid = true },
{ path = "auths_verifier::types::DeviceDID::new_unchecked", reason = "Use DeviceDID::parse() for external input. Use #[allow(clippy::disallowed_methods)] with INVARIANT comment for proven-safe paths.", allow-invalid = true },
{ path = "auths_verifier::types::CanonicalDid::new_unchecked", reason = "Use CanonicalDid::parse() for external input. Use #[allow(clippy::disallowed_methods)] with INVARIANT comment for proven-safe paths.", allow-invalid = true },
{ path = "auths_verifier::core::CommitOid::new_unchecked", reason = "Use CommitOid::parse() for external input. Use #[allow(clippy::disallowed_methods)] with INVARIANT comment for proven-safe paths.", allow-invalid = true },
{ path = "auths_verifier::core::PublicKeyHex::new_unchecked", reason = "Use PublicKeyHex::parse() for external input. Use #[allow(clippy::disallowed_methods)] with INVARIANT comment for proven-safe paths.", allow-invalid = true },
]
61 changes: 61 additions & 0 deletions crates/auths-cli/src/bin/sign.rs
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,28 @@ struct Args {
buffer_file: Option<PathBuf>,
}

fn validate_verify_option(opt: &str) -> Result<()> {
match opt {
"print-pubkey" => return Ok(()),
"hashalg=sha256" | "hashalg=sha512" => return Ok(()),
_ => {}
}

if let Some(value) = opt.strip_prefix("verify-time=")
&& !value.is_empty()
&& value.len() <= 14
&& value.bytes().all(|b| b.is_ascii_digit())
{
return Ok(());
}

bail!(
"disallowed verify option '-O {opt}'\n \
Only these -O options are permitted: verify-time=<timestamp>, print-pubkey, hashalg=sha256, hashalg=sha512\n \
[AUTHS-E0031]"
);
}

fn parse_key_identifier(key_file: &str) -> Result<String> {
if let Some(alias) = key_file.strip_prefix("auths:") {
if alias.is_empty() {
Expand Down Expand Up @@ -187,6 +209,7 @@ fn run_verify(args: &Args) -> Result<()> {
]);
cmd.arg(sig_file);
for opt in &args.verify_options {
validate_verify_option(opt)?;
cmd.arg("-O").arg(opt);
}
let status = cmd
Expand Down Expand Up @@ -219,6 +242,7 @@ fn run_delegate_to_ssh_keygen(args: &Args) -> Result<()> {
cmd.arg("-s").arg(sig);
}
for opt in &args.verify_options {
validate_verify_option(opt)?;
cmd.arg("-O").arg(opt);
}
let status = cmd
Expand Down Expand Up @@ -484,4 +508,41 @@ mod tests {
let result = extract_seed_from_pkcs8(&bad_input);
assert!(result.is_err(), "must reject non-PKCS#8 input");
}

#[test]
fn test_validate_verify_option_valid() {
assert!(validate_verify_option("verify-time=1700000000").is_ok());
assert!(validate_verify_option("verify-time=20260218012319").is_ok());
assert!(validate_verify_option("verify-time=1").is_ok());
assert!(validate_verify_option("print-pubkey").is_ok());
assert!(validate_verify_option("hashalg=sha256").is_ok());
assert!(validate_verify_option("hashalg=sha512").is_ok());
}

#[test]
fn test_validate_verify_option_invalid() {
assert!(validate_verify_option("no-touch-required").is_err());
assert!(validate_verify_option("foo=bar").is_err());
assert!(validate_verify_option("random-option").is_err());
assert!(validate_verify_option("").is_err());
}

#[test]
fn test_validate_verify_option_edge_cases() {
assert!(validate_verify_option("verify-time=").is_err());
assert!(validate_verify_option("verify-time=abc").is_err());
assert!(validate_verify_option("VERIFY-TIME=123").is_err());
assert!(validate_verify_option("hashalg=sha384").is_err());
assert!(validate_verify_option("verify-time=123=456").is_err());
assert!(validate_verify_option(" verify-time=123").is_err());
assert!(validate_verify_option("verify-time=999999999999999").is_err());
}

#[test]
fn test_validate_verify_option_injection_attempts() {
assert!(validate_verify_option("-D /tmp/evil.so").is_err());
assert!(validate_verify_option("--help").is_err());
assert!(validate_verify_option("-t rsa").is_err());
assert!(validate_verify_option("-w /tmp/fido.so").is_err());
}
}
2 changes: 2 additions & 0 deletions crates/auths-cli/src/commands/id/identity.rs
Original file line number Diff line number Diff line change
Expand Up @@ -598,6 +598,8 @@ pub fn handle_id(
let pkcs8_bytes = auths_core::crypto::signer::decrypt_keypair(&encrypted_key, &pass)
.context("Failed to decrypt key")?;
let keypair = auths_id::identity::helpers::load_keypair_from_der_or_seed(&pkcs8_bytes)?;
#[allow(clippy::disallowed_methods)]
// INVARIANT: hex::encode of Ed25519 pubkey always produces valid hex
let public_key_hex = auths_verifier::PublicKeyHex::new_unchecked(hex::encode(
keypair.public_key().as_ref(),
));
Expand Down
7 changes: 6 additions & 1 deletion crates/auths-cli/src/commands/id/register.rs
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,12 @@ pub fn handle_register(repo_path: &Path, registry: &str) -> Result<()> {
Err(RegistrationError::NetworkError(e)) => {
bail!("Failed to connect to registry server: {e}");
}
Err(RegistrationError::LocalDataError(e)) => {
Err(
e @ (RegistrationError::InvalidDidFormat { .. }
| RegistrationError::IdentityLoadError(_)
| RegistrationError::RegistryReadError(_)
| RegistrationError::SerializationError(_)),
) => {
bail!("{e}");
}
Err(e) => {
Expand Down
7 changes: 6 additions & 1 deletion crates/auths-cli/src/commands/init/display.rs
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,12 @@ pub(crate) fn display_ci_result(
pub(crate) fn display_agent_result(out: &Output, result: &auths_sdk::result::AgentIdentityResult) {
out.print_heading("Agent Setup Complete!");
out.newline();
out.println(&format!(" Identity: {}", out.info(&result.agent_did)));
let did_display = result
.agent_did
.as_ref()
.map(|d| d.to_string())
.unwrap_or_else(|| "<pending>".to_string());
out.println(&format!(" Identity: {}", out.info(&did_display)));
let cap_display: Vec<String> = result.capabilities.iter().map(|c| c.to_string()).collect();
out.println(&format!(" Capabilities: {}", cap_display.join(", ")));
out.newline();
Expand Down
7 changes: 6 additions & 1 deletion crates/auths-cli/src/commands/init/gather.rs
Original file line number Diff line number Diff line change
Expand Up @@ -182,7 +182,12 @@ pub(crate) fn submit_registration(
out.println(" Run `auths id register` when you're back online.");
None
}
Err(auths_sdk::error::RegistrationError::LocalDataError(e)) => {
Err(
e @ (auths_sdk::error::RegistrationError::InvalidDidFormat { .. }
| auths_sdk::error::RegistrationError::IdentityLoadError(_)
| auths_sdk::error::RegistrationError::RegistryReadError(_)
| auths_sdk::error::RegistrationError::SerializationError(_)),
) => {
out.print_warn(&format!("Could not prepare registration payload: {e}"));
out.println(" Run `auths id register` to retry.");
None
Expand Down
4 changes: 4 additions & 0 deletions crates/auths-cli/src/commands/org.rs
Original file line number Diff line number Diff line change
Expand Up @@ -662,6 +662,8 @@ pub fn handle_org(
let (stored_did, _role, _encrypted_key) = key_storage
.load_key(&signer_alias)
.with_context(|| format!("Failed to load signer key '{}'", signer_alias))?;
#[allow(clippy::disallowed_methods)]
// INVARIANT: hex::encode of resolved Ed25519 pubkey always produces valid hex
let admin_pk_hex = PublicKeyHex::new_unchecked(hex::encode(
resolver
.resolve(stored_did.as_str())
Expand Down Expand Up @@ -776,6 +778,8 @@ pub fn handle_org(
let (stored_did, _role, _encrypted_key) = key_storage
.load_key(&signer_alias)
.with_context(|| format!("Failed to load signer key '{}'", signer_alias))?;
#[allow(clippy::disallowed_methods)]
// INVARIANT: hex::encode of resolved Ed25519 pubkey always produces valid hex
let admin_pk_hex = PublicKeyHex::new_unchecked(hex::encode(
resolver
.resolve(stored_did.as_str())
Expand Down
4 changes: 2 additions & 2 deletions crates/auths-core/src/policy/device.rs
Original file line number Diff line number Diff line change
Expand Up @@ -78,13 +78,13 @@ impl Action {
/// ```rust
/// use auths_core::policy::{Decision, device::{Action, authorize_device}};
/// use auths_verifier::core::{Attestation, Capability, Ed25519PublicKey, Ed25519Signature};
/// use auths_verifier::types::DeviceDID;
/// use auths_verifier::types::{CanonicalDid, DeviceDID};
/// use chrono::Utc;
///
/// let attestation = Attestation {
/// version: 1,
/// rid: "test".into(),
/// issuer: "did:keri:ETest".into(),
/// issuer: CanonicalDid::new_unchecked("did:keri:ETest"),
/// subject: DeviceDID::new_unchecked("did:key:z6Mk..."),
/// device_public_key: Ed25519PublicKey::from_bytes([0u8; 32]),
/// identity_signature: Ed25519Signature::empty(),
Expand Down
4 changes: 2 additions & 2 deletions crates/auths-core/src/policy/org.rs
Original file line number Diff line number Diff line change
Expand Up @@ -57,13 +57,13 @@ use super::device::Action;
/// ```rust
/// use auths_core::policy::{Decision, device::Action, org::authorize_org_action};
/// use auths_verifier::core::{Attestation, Capability, Ed25519PublicKey, Ed25519Signature, Role};
/// use auths_verifier::types::DeviceDID;
/// use auths_verifier::types::{CanonicalDid, DeviceDID};
/// use chrono::Utc;
///
/// let membership = Attestation {
/// version: 1,
/// rid: "member".into(),
/// issuer: "did:keri:EOrg123".into(),
/// issuer: CanonicalDid::new_unchecked("did:keri:EOrg123"),
/// subject: DeviceDID::new_unchecked("did:key:z6MkAlice"),
/// device_public_key: Ed25519PublicKey::from_bytes([0u8; 32]),
/// identity_signature: Ed25519Signature::empty(),
Expand Down
2 changes: 1 addition & 1 deletion crates/auths-id/src/storage/indexed.rs
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,7 @@ impl IndexedAttestationStorage {
issuer_did: IdentityDID::new_unchecked(att.issuer.as_str()),
device_did: att.subject.clone(),
git_ref: git_ref.to_string(),
commit_oid: CommitOid::new_unchecked(commit_oid),
commit_oid: CommitOid::parse(commit_oid).ok(),
revoked_at: att.revoked_at,
expires_at: att.expires_at,
updated_at: att.timestamp.unwrap_or(now),
Expand Down
5 changes: 2 additions & 3 deletions crates/auths-id/src/storage/registry/backend.rs
Original file line number Diff line number Diff line change
Expand Up @@ -631,14 +631,13 @@ pub trait RegistryBackend: Send + Sync {
source_filename: entry.filename.clone(),
});
} else {
// Invalid entry - include with minimal info for debugging
members.push(MemberView {
did: entry.did.clone(),
status,
role: None,
capabilities: vec![],
issuer: IdentityDID::new_unchecked(String::new()),
rid: ResourceId::new(String::new()),
issuer: entry.org.clone(),
rid: ResourceId::new(""),
revoked_at: None,
expires_at: None,
timestamp: None,
Expand Down
14 changes: 8 additions & 6 deletions crates/auths-index/src/index.rs
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,8 @@ pub struct IndexedAttestation {
pub device_did: DeviceDID,
/// Git ref path (e.g., refs/auths/devices/nodes/...)
pub git_ref: String,
/// Git commit OID for loading full attestation
pub commit_oid: CommitOid,
/// Git commit OID for loading full attestation (None when OID is not yet known)
pub commit_oid: Option<CommitOid>,
/// When this attestation was revoked, if applicable
pub revoked_at: Option<DateTime<Utc>>,
/// Optional expiration timestamp
Expand Down Expand Up @@ -107,7 +107,7 @@ impl AttestationIndex {
stmt.bind((2, att.issuer_did.as_str()))?;
stmt.bind((3, att.device_did.as_str()))?;
stmt.bind((4, att.git_ref.as_str()))?;
stmt.bind((5, att.commit_oid.as_str()))?;
stmt.bind((5, att.commit_oid.as_ref().map(|c| c.as_str())))?;
stmt.bind((6, revoked_at_str.as_deref()))?;
stmt.bind((7, expires_at_str.as_deref()))?;
stmt.bind((8, updated_at_str.as_str()))?;
Expand Down Expand Up @@ -259,7 +259,7 @@ impl AttestationIndex {
let issuer_did: String = stmt.read(1)?;
let device_did: String = stmt.read(2)?;
let git_ref: String = stmt.read(3)?;
let commit_oid: String = stmt.read(4)?;
let commit_oid: Option<String> = stmt.read(4)?;
let revoked_at_str: Option<String> = stmt.read(5)?;
let expires_at_str: Option<String> = stmt.read(6)?;
let updated_at_str: String = stmt.read(7)?;
Expand All @@ -281,7 +281,9 @@ impl AttestationIndex {
issuer_did: IdentityDID::new_unchecked(issuer_did),
device_did: DeviceDID::new_unchecked(device_did),
git_ref,
commit_oid: CommitOid::new_unchecked(commit_oid),
commit_oid: commit_oid
.filter(|s| !s.is_empty())
.and_then(|s| CommitOid::parse(&s).ok()),
revoked_at,
expires_at,
updated_at,
Expand Down Expand Up @@ -462,7 +464,7 @@ mod tests {
issuer_did: IdentityDID::new_unchecked("did:key:issuer123"),
device_did: DeviceDID::new_unchecked(device),
git_ref: format!("refs/auths/devices/nodes/{}/signatures", device),
commit_oid: CommitOid::new_unchecked("abc123"),
commit_oid: None,
revoked_at,
expires_at: Some(Utc::now() + Duration::days(30)),
updated_at: Utc::now(),
Expand Down
2 changes: 1 addition & 1 deletion crates/auths-index/src/rebuild.rs
Original file line number Diff line number Diff line change
Expand Up @@ -123,7 +123,7 @@ fn extract_attestation_from_ref(
issuer_did: IdentityDID::new_unchecked(&issuer_did),
device_did: DeviceDID::new_unchecked(&device_did),
git_ref: ref_name.to_string(),
commit_oid: CommitOid::new_unchecked(commit.id().to_string()),
commit_oid: CommitOid::parse(&commit.id().to_string()).ok(),
revoked_at,
expires_at,
updated_at,
Expand Down
2 changes: 1 addition & 1 deletion crates/auths-index/src/schema.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ CREATE TABLE IF NOT EXISTS attestations (
issuer_did TEXT NOT NULL,
device_did TEXT NOT NULL,
git_ref TEXT NOT NULL,
commit_oid TEXT NOT NULL,
commit_oid TEXT,
revoked_at TEXT,
expires_at TEXT,
updated_at TEXT NOT NULL
Expand Down
10 changes: 8 additions & 2 deletions crates/auths-radicle/src/attestation.rs
Original file line number Diff line number Diff line change
Expand Up @@ -224,11 +224,16 @@ impl TryFrom<RadAttestation> for Attestation {
type Error = AttestationConversionError;

fn try_from(rad: RadAttestation) -> Result<Self, Self::Error> {
#[allow(clippy::disallowed_methods)]
// INVARIANT: rad.canonical_payload.did is a validated radicle Did
let issuer = CanonicalDid::new_unchecked(rad.canonical_payload.did.to_string());
#[allow(clippy::disallowed_methods)] // INVARIANT: rad.device_did is a validated radicle Did
let subject = DeviceDID::new_unchecked(rad.device_did.to_string());
Ok(Attestation {
version: 1,
rid: ResourceId::new(rad.canonical_payload.rid.to_string()),
issuer: CanonicalDid::new_unchecked(rad.canonical_payload.did.to_string()),
subject: DeviceDID::new_unchecked(rad.device_did.to_string()),
issuer,
subject,
device_public_key: Ed25519PublicKey::try_from_slice(rad.device_public_key.as_ref())
.map_err(|_e| {
AttestationConversionError::InvalidPublicKeyLength(
Expand Down Expand Up @@ -288,6 +293,7 @@ impl TryFrom<&Attestation> for RadAttestation {
}

#[cfg(test)]
#[allow(clippy::disallowed_methods)]
mod tests {
use super::*;

Expand Down
4 changes: 4 additions & 0 deletions crates/auths-radicle/src/storage.rs
Original file line number Diff line number Diff line change
Expand Up @@ -184,6 +184,7 @@ impl AuthsStorage for GitRadicleStorage {
}

fn load_key_state(&self, identity_did: &Did) -> Result<KeyState, BridgeError> {
#[allow(clippy::disallowed_methods)] // INVARIANT: identity_did is a validated radicle Did
let did = IdentityDID::new_unchecked(identity_did.to_string());
let repo = self.lock_repo();
let events = self.read_kel_events(&repo, &did)?;
Expand All @@ -204,6 +205,7 @@ impl AuthsStorage for GitRadicleStorage {
device_did: &Did,
identity_did: &Did,
) -> Result<Attestation, BridgeError> {
#[allow(clippy::disallowed_methods)] // INVARIANT: device_did is a validated radicle Did
let dev_did = DeviceDID::new_unchecked(device_did.to_string());
let nid = device_did_to_nid(device_did)?;
let did_key_ref = self.layout.device_did_key_ref(&nid);
Expand Down Expand Up @@ -261,6 +263,8 @@ impl AuthsStorage for GitRadicleStorage {
device_did: &Did,
_repo_id: &RepoId,
) -> Result<Option<Did>, BridgeError> {
#[allow(clippy::disallowed_methods)]
// INVARIANT: synthetic DID used only as error context identifier
let did_context = IdentityDID::new_unchecked(format!("lookup:{device_did}"));
let nid = device_did_to_nid(device_did)?;
let sig_ref = self.layout.device_signatures_ref(&nid);
Expand Down
5 changes: 4 additions & 1 deletion crates/auths-radicle/src/verify.rs
Original file line number Diff line number Diff line change
Expand Up @@ -268,8 +268,11 @@ impl<S: AuthsStorage> RadicleAuthsBridge for DefaultBridge<S> {

// Step 6: Evaluate policy (revocation, expiry)
let decision = evaluate_compiled(&attestation, &self.policy, request.now).map_err(|e| {
#[allow(clippy::disallowed_methods)]
// INVARIANT: identity_did is a validated radicle Did
let did = IdentityDID::new_unchecked(identity_did.to_string());
BridgeError::PolicyEvaluation {
did: IdentityDID::new_unchecked(identity_did.to_string()),
did,
reason: e.to_string(),
}
})?;
Expand Down
Loading
Loading