diff --git a/crates/auths-cli/src/commands/agent.rs b/crates/auths-cli/src/commands/agent.rs index debea1c4..4b0698b6 100644 --- a/crates/auths-cli/src/commands/agent.rs +++ b/crates/auths-cli/src/commands/agent.rs @@ -68,8 +68,13 @@ pub enum AgentSubcommand { /// Unlock the agent (re-load keys) Unlock { /// Key alias to unlock - #[arg(long, default_value = "default", help = "Key alias to unlock")] - key: String, + #[arg( + long = "agent-key-alias", + visible_alias = "key", + default_value = "default", + help = "Key alias to unlock" + )] + agent_key_alias: String, }, /// Install as a system service (launchd on macOS, systemd on Linux) @@ -177,7 +182,7 @@ pub fn handle_agent(cmd: AgentCommand) -> Result<()> { AgentSubcommand::Status => show_status(), AgentSubcommand::Env { shell } => output_env(shell), AgentSubcommand::Lock => lock_agent(), - AgentSubcommand::Unlock { key } => unlock_agent(&key), + AgentSubcommand::Unlock { agent_key_alias } => unlock_agent(&agent_key_alias), AgentSubcommand::InstallService { dry_run, force, diff --git a/crates/auths-cli/src/commands/artifact/mod.rs b/crates/auths-cli/src/commands/artifact/mod.rs index 2ff47a5e..4828a4ff 100644 --- a/crates/auths-cli/src/commands/artifact/mod.rs +++ b/crates/auths-cli/src/commands/artifact/mod.rs @@ -34,16 +34,21 @@ pub enum ArtifactSubcommand { /// Local alias of the identity key (used for signing). Omit for CI device-only signing. #[arg( long, + visible_alias = "ika", help = "Local alias of the identity key. Omit for device-only CI signing." )] identity_key_alias: Option, /// Local alias of the device key (used for dual-signing). - #[arg(long, help = "Local alias of the device key (used for dual-signing).")] + #[arg( + long, + visible_alias = "dka", + help = "Local alias of the device key (used for dual-signing)." + )] device_key_alias: String, /// Number of days until the signature expires. - #[arg(long, value_name = "N")] + #[arg(long, visible_alias = "days", value_name = "N")] expires_in_days: Option, /// Optional note to embed in the attestation. diff --git a/crates/auths-cli/src/commands/device/authorization.rs b/crates/auths-cli/src/commands/device/authorization.rs index 734c6c84..9b5b7006 100644 --- a/crates/auths-cli/src/commands/device/authorization.rs +++ b/crates/auths-cli/src/commands/device/authorization.rs @@ -80,17 +80,23 @@ pub enum DeviceSubcommand { /// Authorize a new device to act on behalf of the identity. #[command(visible_alias = "add")] Link { - #[arg(long, help = "Local alias of the *identity's* key (used for signing).")] + #[arg( + long, + visible_alias = "ika", + help = "Local alias of the *identity's* key (used for signing)." + )] identity_key_alias: String, #[arg( long, + visible_alias = "dka", help = "Local alias of the *new device's* key (must be imported first)." )] device_key_alias: String, #[arg( long, + visible_alias = "device", help = "Identity ID of the new device being authorized (must match device-key-alias)." )] device_did: String, @@ -111,6 +117,7 @@ pub enum DeviceSubcommand { #[arg( long, + visible_alias = "days", value_name = "DAYS", help = "Optional number of days until this device authorization expires." )] @@ -132,7 +139,11 @@ pub enum DeviceSubcommand { /// Revoke an existing device authorization using the identity key. Revoke { - #[arg(long, help = "Identity ID of the device authorization to revoke.")] + #[arg( + long, + visible_alias = "device", + help = "Identity ID of the device authorization to revoke." + )] device_did: String, #[arg( @@ -147,7 +158,11 @@ pub enum DeviceSubcommand { /// Resolve a device DID to its controller identity DID. Resolve { - #[arg(long, help = "The device DID to resolve (e.g. did:key:z6Mk...).")] + #[arg( + long, + visible_alias = "device", + help = "The device DID to resolve (e.g. did:key:z6Mk...)." + )] device_did: String, }, @@ -160,24 +175,31 @@ pub enum DeviceSubcommand { /// Extend the expiration date of an existing device authorization. Extend { - #[arg(long, help = "Identity ID of the device authorization to extend.")] + #[arg( + long, + visible_alias = "device", + help = "Identity ID of the device authorization to extend." + )] device_did: String, #[arg( - long, + long = "expires-in-days", + visible_alias = "days", value_name = "DAYS", help = "Number of days to extend the expiration by (from now)." )] - days: i64, + expires_in_days: i64, #[arg( long = "identity-key-alias", + visible_alias = "ika", help = "Local alias of the *identity's* key (required for re-signing)." )] identity_key_alias: String, #[arg( long = "device-key-alias", + visible_alias = "dka", help = "Local alias of the *device's* key (required for re-signing)." )] device_key_alias: String, @@ -297,14 +319,14 @@ pub fn handle_device( DeviceSubcommand::Extend { device_did, - days, + expires_in_days, identity_key_alias, device_key_alias, } => handle_extend( &repo_path, &config, &device_did, - days, + expires_in_days, &identity_key_alias, &device_key_alias, passphrase_provider, diff --git a/crates/auths-cli/src/commands/device/pair/mod.rs b/crates/auths-cli/src/commands/device/pair/mod.rs index 1188e82e..a99d5b09 100644 --- a/crates/auths-cli/src/commands/device/pair/mod.rs +++ b/crates/auths-cli/src/commands/device/pair/mod.rs @@ -37,9 +37,14 @@ pub struct PairCommand { #[clap(long)] pub no_qr: bool, - /// Custom expiry time in seconds (default: 300 = 5 minutes) - #[clap(long, value_name = "SECONDS", default_value = "300")] - pub expiry: u64, + /// Custom timeout in seconds for the pairing session (default: 300 = 5 minutes) + #[clap( + long, + visible_alias = "expiry", + value_name = "SECONDS", + default_value = "300" + )] + pub timeout: u64, /// Skip registry server (offline mode, for testing) #[clap(long)] @@ -72,7 +77,7 @@ pub fn handle_pair( match (&cmd.join, &cmd.registry, cmd.offline) { // Offline mode takes priority (None, _, true) => { - offline::handle_initiate_offline(cmd.no_qr, cmd.expiry, &cmd.capabilities) + offline::handle_initiate_offline(cmd.no_qr, cmd.timeout, &cmd.capabilities) } // Join with explicit registry -> online join @@ -102,7 +107,7 @@ pub fn handle_pair( http_client, registry, cmd.no_qr, - cmd.expiry, + cmd.timeout, &cmd.capabilities, env_config, )) @@ -115,7 +120,7 @@ pub fn handle_pair( rt.block_on(lan::handle_initiate_lan( cmd.no_qr, cmd.no_mdns, - cmd.expiry, + cmd.timeout, &cmd.capabilities, env_config, )) @@ -129,7 +134,7 @@ pub fn handle_pair( http_client, DEFAULT_REGISTRY, cmd.no_qr, - cmd.expiry, + cmd.timeout, &cmd.capabilities, env_config, )) diff --git a/crates/auths-cli/src/commands/device/verify_attestation.rs b/crates/auths-cli/src/commands/device/verify_attestation.rs index 1cf18786..d18b0055 100644 --- a/crates/auths-cli/src/commands/device/verify_attestation.rs +++ b/crates/auths-cli/src/commands/device/verify_attestation.rs @@ -43,7 +43,7 @@ pub struct VerifyCommand { /// /// Looks up the public key from pinned identity store or roots.json. /// Uses --trust policy to determine behavior for unknown identities. - #[arg(long = "issuer-did", value_parser)] + #[arg(long = "issuer-did", visible_alias = "issuer", value_parser)] pub issuer_did: Option, /// Trust policy for unknown identities. diff --git a/crates/auths-cli/src/commands/emergency.rs b/crates/auths-cli/src/commands/emergency.rs index d4779b01..2f7e5982 100644 --- a/crates/auths-cli/src/commands/emergency.rs +++ b/crates/auths-cli/src/commands/emergency.rs @@ -139,7 +139,7 @@ pub struct ReportCommand { pub events: usize, /// Output file path (defaults to stdout). - #[arg(long = "file", short = 'o')] + #[arg(long = "output", visible_alias = "file", short = 'o')] pub output_file: Option, /// Path to the Auths repository. diff --git a/crates/auths-cli/src/commands/id/identity.rs b/crates/auths-cli/src/commands/id/identity.rs index ad64f632..9e1ddf10 100644 --- a/crates/auths-cli/src/commands/id/identity.rs +++ b/crates/auths-cli/src/commands/id/identity.rs @@ -171,8 +171,10 @@ pub enum IdSubcommand { alias: String, /// Output file path for the JSON bundle. - #[arg(long, short, help = "Output file path")] - output: PathBuf, + // Named `output_file` because the top-level `Cli` has a global `--output` + // (OutputFormat) arg; clap panics on the field-name collision. + #[arg(long = "output", short = 'o')] + output_file: PathBuf, /// TTL in seconds. The bundle will fail verification after this many seconds. #[arg( @@ -517,13 +519,13 @@ pub fn handle_id( IdSubcommand::ExportBundle { alias, - output, + output_file, max_age_secs, } => { println!("šŸ“¦ Exporting identity bundle..."); println!(" Using Repository: {:?}", repo_path); println!(" Key Alias: {}", alias); - println!(" Output File: {:?}", output); + println!(" Output File: {:?}", output_file); // Load identity let identity_storage = RegistryIdentityStorage::new(repo_path.clone()); @@ -565,14 +567,17 @@ pub fn handle_id( // Write to output file let json = serde_json::to_string_pretty(&bundle) .context("Failed to serialize identity bundle")?; - fs::write(&output, &json) - .with_context(|| format!("Failed to write bundle to {:?}", output))?; + fs::write(&output_file, &json) + .with_context(|| format!("Failed to write bundle to {:?}", output_file))?; println!("\nāœ… Identity bundle exported successfully!"); - println!(" Output: {:?}", output); + println!(" Output: {:?}", output_file); println!(" Attestations: {}", bundle.attestation_chain.len()); println!("\nUsage in CI:"); - println!(" auths verify-commit --identity-bundle {:?} HEAD", output); + println!( + " auths verify-commit --identity-bundle {:?} HEAD", + output_file + ); Ok(()) } diff --git a/crates/auths-cli/src/commands/key.rs b/crates/auths-cli/src/commands/key.rs index a3d0d346..51b932a7 100644 --- a/crates/auths-cli/src/commands/key.rs +++ b/crates/auths-cli/src/commands/key.rs @@ -32,8 +32,12 @@ pub enum KeySubcommand { /// Export a stored key in various formats (requires passphrase for some formats). Export { /// Local alias of the key to export. - #[arg(long, help = "Local alias of the key to export.")] - alias: String, + #[arg( + long = "key-alias", + visible_alias = "alias", + help = "Local alias of the key to export." + )] + key_alias: String, /// Passphrase to decrypt the key (needed for 'pem'/'pub' formats). #[arg( @@ -53,15 +57,23 @@ pub enum KeySubcommand { /// Remove a key from the platform's secure storage by alias. Delete { /// Local alias of the key to remove. - #[arg(long, help = "Local alias of the key to remove.")] - alias: String, + #[arg( + long = "key-alias", + visible_alias = "alias", + help = "Local alias of the key to remove." + )] + key_alias: String, }, /// Import an Ed25519 key from a 32-byte seed file and store it encrypted. Import { /// Local alias to assign to the imported key. - #[arg(long, help = "Local alias to assign to the imported key.")] - alias: String, + #[arg( + long = "key-alias", + visible_alias = "alias", + help = "Local alias to assign to the imported key." + )] + key_alias: String, /// Path to the file containing the raw 32-byte Ed25519 seed. #[arg( @@ -96,8 +108,8 @@ pub enum KeySubcommand { /// --dst-backend file --dst-file /tmp/ci-keychain.enc --dst-passphrase "$CI_PASS" CopyBackend { /// Alias of the key to copy from the current (source) keychain. - #[arg(long)] - alias: String, + #[arg(long = "key-alias", visible_alias = "alias")] + key_alias: String, /// Destination backend type. Currently supported: "file". #[arg(long)] @@ -118,26 +130,26 @@ pub fn handle_key(cmd: KeyCommand) -> Result<()> { match cmd.command { KeySubcommand::List => key_list(), KeySubcommand::Export { - alias, + key_alias, passphrase, format, - } => key_export(&alias, &passphrase, format), - KeySubcommand::Delete { alias } => key_delete(&alias), + } => key_export(&key_alias, &passphrase, format), + KeySubcommand::Delete { key_alias } => key_delete(&key_alias), KeySubcommand::Import { - alias, + key_alias, seed_file, controller_did, } => { let identity_did = IdentityDID::new(controller_did); - key_import(&alias, &seed_file, &identity_did) + key_import(&key_alias, &seed_file, &identity_did) } KeySubcommand::CopyBackend { - alias, + key_alias, dst_backend, dst_file, dst_passphrase, } => key_copy_backend( - &alias, + &key_alias, &dst_backend, dst_file.as_ref(), dst_passphrase.as_deref(), diff --git a/crates/auths-cli/src/commands/org.rs b/crates/auths-cli/src/commands/org.rs index 9324e036..f5503868 100644 --- a/crates/auths-cli/src/commands/org.rs +++ b/crates/auths-cli/src/commands/org.rs @@ -66,8 +66,9 @@ pub struct OrgCommand { /// Subcommands for managing authorizations issued by this identity. #[derive(Subcommand, Debug, Clone)] pub enum OrgSubcommand { - /// Initialize a new organization identity - Init { + /// Create a new organization identity + #[command(visible_alias = "init")] + Create { /// Organization name #[arg(long)] name: String, @@ -81,8 +82,8 @@ pub enum OrgSubcommand { metadata_file: Option, }, Attest { - #[arg(long)] - subject: String, + #[arg(long = "subject-did", visible_alias = "subject")] + subject_did: String, #[arg(long)] payload_file: PathBuf, #[arg(long)] @@ -93,16 +94,16 @@ pub enum OrgSubcommand { signer_alias: Option, }, Revoke { - #[arg(long)] - subject: String, + #[arg(long = "subject-did", visible_alias = "subject")] + subject_did: String, #[arg(long)] note: Option, #[arg(long)] signer_alias: Option, }, Show { - #[arg(long)] - subject: String, + #[arg(long = "subject-did", visible_alias = "subject")] + subject_did: String, #[arg(long, action = ArgAction::SetTrue)] include_revoked: bool, }, @@ -117,8 +118,8 @@ pub enum OrgSubcommand { org: String, /// Member identity ID to add - #[arg(long)] - member: String, + #[arg(long = "member-did", visible_alias = "member")] + member_did: String, /// Role to assign (admin, member, readonly) #[arg(long, value_enum)] @@ -144,8 +145,8 @@ pub enum OrgSubcommand { org: String, /// Member identity ID to revoke - #[arg(long)] - member: String, + #[arg(long = "member-did", visible_alias = "member")] + member_did: String, /// Reason for revocation #[arg(long)] @@ -198,7 +199,7 @@ pub fn handle_org( let resolver: DefaultDidResolver = DefaultDidResolver::with_repo(&repo_path); match cmd.subcommand { - OrgSubcommand::Init { + OrgSubcommand::Create { name, local_key_alias, metadata_file, @@ -389,7 +390,7 @@ pub fn handle_org( } OrgSubcommand::Attest { - subject, // The subject DID (String) + subject_did, // The subject DID (String) payload_file, // Path to the JSON payload note, // Optional note (String) expires_at, // Optional RFC3339 expiration string @@ -432,11 +433,11 @@ pub fn handle_org( let _pkcs8_bytes = decrypt_keypair(&encrypted_key, &passphrase) .context("Failed to decrypt signer key (invalid passphrase?)")?; - let subject_did = DeviceDID::new(subject.clone()); + let subject_device_did = DeviceDID::new(subject_did.clone()); // --- Resolve device public key using the custom resolver IF did:key --- - let device_resolved = resolver.resolve(&subject).with_context(|| { - format!("Failed to resolve public key for subject: {}", subject) + let device_resolved = resolver.resolve(&subject_did).with_context(|| { + format!("Failed to resolve public key for subject: {}", subject_did) })?; let device_pk_bytes = *device_resolved.public_key(); @@ -457,7 +458,7 @@ pub fn handle_org( now, &rid, &controller_did, - &subject_did, + &subject_device_did, device_pk_bytes.as_bytes(), Some(payload), &meta, @@ -478,18 +479,18 @@ pub fn handle_org( println!( "\nāœ… Org attestation created successfully from '{}' → '{}'", - controller_did, subject_did + controller_did, subject_device_did ); Ok(()) } OrgSubcommand::Revoke { - subject, + subject_did, note, signer_alias, } => { - println!("šŸ›‘ Revoking org authorization for subject: {subject}"); + println!("šŸ›‘ Revoking org authorization for subject: {subject_did}"); println!(" Using Repository: {:?}", repo_path); println!(" Using Identity Ref: '{}'", config.identity_ref); println!( @@ -520,13 +521,13 @@ pub fn handle_org( decrypt_keypair(&encrypted_key, &pass).context("Failed to decrypt identity key")?; // Allow both did:key and did:keri as subject input - let subject_did = DeviceDID::new(subject.clone()); + let subject_device_did = DeviceDID::new(subject_did.clone()); let now = Utc::now(); // Look up the subject's public key from existing attestations let attestation_storage = RegistryAttestationStorage::new(repo_path.clone()); let existing = attestation_storage - .load_attestations_for_device(&subject_did) + .load_attestations_for_device(&subject_device_did) .context("Failed to load attestations for subject")?; let device_public_key = existing .iter() @@ -539,7 +540,7 @@ pub fn handle_org( let attestation = create_signed_revocation( &rid, &controller_did, - &subject_did, + &subject_device_did, device_public_key.as_bytes(), note, None, @@ -555,21 +556,21 @@ pub fn handle_org( .export(&auths_verifier::VerifiedAttestation::dangerous_from_unchecked(attestation)) .context("Failed to write revocation")?; - println!("\nāœ… Revoked authorization for subject {subject}"); + println!("\nāœ… Revoked authorization for subject {subject_did}"); Ok(()) } OrgSubcommand::Show { - subject, + subject_did, include_revoked, } => { let attestation_storage = RegistryAttestationStorage::new(repo_path.clone()); let resolver = DefaultDidResolver::with_repo(&repo_path); let group = AttestationGroup::from_list(attestation_storage.load_all_attestations()?); - let subject_did = DeviceDID(subject.clone()); - if let Some(list) = group.by_device.get(subject_did.as_str()) { + let subject_device_did = DeviceDID(subject_did.clone()); + if let Some(list) = group.by_device.get(subject_device_did.as_str()) { for (i, att) in list.iter().enumerate() { if !include_revoked && (att.is_revoked() || att.expires_at.is_some_and(|e| Utc::now() > e)) @@ -597,7 +598,7 @@ pub fn handle_org( } } } else { - println!("No authorizations found for subject: {}", subject); + println!("No authorizations found for subject: {}", subject_did); } Ok(()) @@ -631,7 +632,7 @@ pub fn handle_org( OrgSubcommand::AddMember { org, - member, + member_did: member, role: cli_role, capabilities, signer_alias, @@ -793,7 +794,7 @@ pub fn handle_org( OrgSubcommand::RevokeMember { org, - member, + member_did: member, note, signer_alias, } => { diff --git a/crates/auths-cli/src/commands/policy.rs b/crates/auths-cli/src/commands/policy.rs index 04e13e3c..4a8c0870 100644 --- a/crates/auths-cli/src/commands/policy.rs +++ b/crates/auths-cli/src/commands/policy.rs @@ -222,7 +222,11 @@ fn handle_lint(cmd: LintCommand) -> Result<()> { limits.max_json_bytes )); } - return Ok(()); + anyhow::bail!( + "file exceeds size limit: {} > {}", + bytes, + limits.max_json_bytes + ); } // Parse JSON @@ -254,6 +258,7 @@ fn handle_lint(cmd: LintCommand) -> Result<()> { } else { out.println(&format!("{} Invalid JSON: {}", out.error("x"), e)); } + anyhow::bail!("lint failed: {}", e); } } diff --git a/crates/auths-cli/src/commands/sign.rs b/crates/auths-cli/src/commands/sign.rs index 80084aff..16680e6a 100644 --- a/crates/auths-cli/src/commands/sign.rs +++ b/crates/auths-cli/src/commands/sign.rs @@ -100,7 +100,7 @@ pub struct SignCommand { pub device_key_alias: Option, /// Number of days until the signature expires (for artifact signing). - #[arg(long, value_name = "N")] + #[arg(long, visible_alias = "days", value_name = "N")] pub expires_in_days: Option, /// Optional note to embed in the attestation (for artifact signing). diff --git a/crates/auths-cli/src/commands/unified_verify.rs b/crates/auths-cli/src/commands/unified_verify.rs index e463b8d8..2db3b47a 100644 --- a/crates/auths-cli/src/commands/unified_verify.rs +++ b/crates/auths-cli/src/commands/unified_verify.rs @@ -79,7 +79,7 @@ pub struct UnifiedVerifyCommand { pub issuer_pk: Option, /// Issuer identity ID for attestation trust-based key resolution. - #[arg(long = "issuer-did")] + #[arg(long = "issuer-did", visible_alias = "issuer")] pub issuer_did: Option, /// Path to witness receipts JSON file. diff --git a/crates/auths-cli/src/commands/witness.rs b/crates/auths-cli/src/commands/witness.rs index ef0fef6c..061a022a 100644 --- a/crates/auths-cli/src/commands/witness.rs +++ b/crates/auths-cli/src/commands/witness.rs @@ -22,7 +22,8 @@ pub struct WitnessCommand { #[derive(Subcommand, Debug, Clone)] pub enum WitnessSubcommand { /// Start the witness HTTP server. - Serve { + #[command(visible_alias = "serve")] + Start { /// Address to bind to (e.g., "127.0.0.1:3333"). #[clap(long, default_value = "127.0.0.1:3333")] bind: SocketAddr, @@ -32,7 +33,7 @@ pub enum WitnessSubcommand { db_path: PathBuf, /// Witness DID (auto-generated if not provided). - #[clap(long)] + #[clap(long, visible_alias = "witness")] witness_did: Option, }, @@ -57,7 +58,7 @@ pub enum WitnessSubcommand { /// Handle witness commands. pub fn handle_witness(cmd: WitnessCommand, repo_opt: Option) -> Result<()> { match cmd.subcommand { - WitnessSubcommand::Serve { + WitnessSubcommand::Start { bind, db_path, witness_did, diff --git a/tests/e2e/helpers/cli.py b/tests/e2e/helpers/cli.py index 8ab4bed2..622c205a 100644 --- a/tests/e2e/helpers/cli.py +++ b/tests/e2e/helpers/cli.py @@ -69,6 +69,54 @@ def run_auths( ) +def get_identity_did(binary: Path, env: dict[str, str]) -> str: + """Extract the controller DID from `auths id show --json`.""" + result = run_auths(binary, ["id", "show", "--json"], env=env) + result.assert_success() + return result.json["data"]["controller_did"] + + +def get_device_did(binary: Path, env: dict[str, str]) -> str: + """Extract the first device DID from `auths status --json`.""" + result = run_auths(binary, ["status", "--json"], env=env) + result.assert_success() + devices = result.json["data"]["devices"]["devices_detail"] + assert len(devices) > 0, "No devices found in status output" + return devices[0]["device_did"] + + +def export_attestation(env: dict[str, str], out_path: Path) -> dict: + """Extract the first attestation from the auths git repo to a file. + + Returns the parsed attestation JSON. + """ + auths_home = Path(env["AUTHS_HOME"]) + + ls = run_git( + ["ls-tree", "-r", "--name-only", "refs/auths/registry"], + cwd=auths_home, + env=env, + ) + ls.assert_success() + + att_path = None + for line in ls.stdout.splitlines(): + if line.endswith("/attestation.json"): + att_path = line + break + assert att_path is not None, "No attestation found in auths repo" + + show = run_git( + ["show", f"refs/auths/registry:{att_path}"], + cwd=auths_home, + env=env, + ) + show.assert_success() + + out_path.write_text(show.stdout) + return json.loads(show.stdout) + + def run_git( args: list[str], *, diff --git a/tests/e2e/test_device_attestation.py b/tests/e2e/test_device_attestation.py index 1a26ddaf..b76a2b0e 100644 --- a/tests/e2e/test_device_attestation.py +++ b/tests/e2e/test_device_attestation.py @@ -2,137 +2,96 @@ import pytest -from helpers.cli import run_auths +from helpers.cli import export_attestation, get_device_did, run_auths + + +def _link_device(auths_bin, env, *, capabilities=None, expires_in_days=None): + """Link a device and return the CLI result.""" + did = get_device_did(auths_bin, env) + args = [ + "device", + "link", + "--identity-key-alias", + "main", + "--device-key-alias", + "main", + "--device-did", + did, + ] + if capabilities: + args += ["--capabilities", capabilities] + if expires_in_days: + args += ["--expires-in-days", str(expires_in_days)] + return run_auths(auths_bin, args, env=env) @pytest.mark.requires_binary class TestDeviceAttestation: def test_device_link(self, auths_bin, init_identity): - result = run_auths( - auths_bin, - [ - "device", - "link", - "--identity-key-alias", - "default", - "--device-key-alias", - "default", - ], - env=init_identity, - ) + result = _link_device(auths_bin, init_identity) if result.returncode != 0: - # GAP: device link may require different arguments pytest.skip(f"device link not available: {result.stderr}") def test_device_list_after_link(self, auths_bin, init_identity): - link = run_auths( - auths_bin, - [ - "device", - "link", - "--identity-key-alias", - "default", - "--device-key-alias", - "default", - ], - env=init_identity, - ) + link = _link_device(auths_bin, init_identity) if link.returncode != 0: pytest.skip("device link not available") list_result = run_auths(auths_bin, ["device", "list"], env=init_identity) list_result.assert_success() - # GAP: does `device list` support --json? assert len(list_result.stdout.strip()) > 0 def test_device_revoke(self, auths_bin, init_identity): - link = run_auths( - auths_bin, - [ - "device", - "link", - "--identity-key-alias", - "default", - "--device-key-alias", - "default", - ], - env=init_identity, - ) + link = _link_device(auths_bin, init_identity) if link.returncode != 0: pytest.skip("device link not available") - # Extract device DID from link output or device list - list_result = run_auths(auths_bin, ["device", "list"], env=init_identity) - list_result.assert_success() + did = get_device_did(auths_bin, init_identity) - # GAP: need to extract device DID from output - # For now, test that revoke command is accepted revoke = run_auths( auths_bin, [ "device", "revoke", "--device-did", - "did:key:z6MkTest", + did, "--identity-key-alias", - "default", + "main", ], env=init_identity, ) - # Revoke of nonexistent device should fail gracefully + # Revoke should succeed or fail gracefully assert revoke.returncode in (0, 1) - def test_device_verify(self, auths_bin, init_identity): - link = run_auths( + def test_device_verify(self, auths_bin, init_identity, tmp_path): + att_file = tmp_path / "attestation.json" + att_data = export_attestation(init_identity, att_file) + issuer_pk = att_data["device_public_key"] + + verify = run_auths( auths_bin, [ "device", - "link", - "--identity-key-alias", - "default", - "--device-key-alias", - "default", + "verify", + "--attestation", + str(att_file), + "--issuer-pk", + issuer_pk, ], env=init_identity, ) - if link.returncode != 0: - pytest.skip("device link not available") - - verify = run_auths(auths_bin, ["device", "verify"], env=init_identity) - if verify.returncode != 0: - pytest.skip(f"device verify not available: {verify.stderr}") + verify.assert_success() def test_attest_agent(self, auths_bin, init_identity): - result = run_auths( - auths_bin, - [ - "attest", - "--subject", - "did:key:z6MkTestAgent", - "--capabilities", - "sign:commit", - "--signer-type", - "agent", - ], - env=init_identity, + result = _link_device( + auths_bin, init_identity, capabilities="sign:commit" ) if result.returncode != 0: - # GAP: attest may require linked device first - pytest.skip(f"attest not available: {result.stderr}") + pytest.skip(f"device link with capabilities not available: {result.stderr}") def test_attest_with_expiry(self, auths_bin, init_identity): - result = run_auths( - auths_bin, - [ - "attest", - "--subject", - "did:key:z6MkTestAgent2", - "--capabilities", - "sign:commit", - "--expires-in", - "1h", - ], - env=init_identity, + result = _link_device( + auths_bin, init_identity, expires_in_days=1 ) if result.returncode != 0: - pytest.skip(f"attest with expiry not available: {result.stderr}") + pytest.skip(f"device link with expiry not available: {result.stderr}") diff --git a/tests/e2e/test_git_signing.py b/tests/e2e/test_git_signing.py index 02bccd9b..aab6add0 100644 --- a/tests/e2e/test_git_signing.py +++ b/tests/e2e/test_git_signing.py @@ -1,6 +1,7 @@ """E2E tests for git commit signing and verification.""" import shutil +from pathlib import Path import pytest @@ -8,6 +9,26 @@ from helpers.git import configure_signing, make_commit +def _generate_allowed_signers(auths_bin, git_repo: Path, env: dict) -> Path: + """Generate allowed-signers file inside the git repo's .auths/ dir.""" + auths_dir = git_repo / ".auths" + auths_dir.mkdir(exist_ok=True) + signers_file = auths_dir / "allowed_signers" + run_auths( + auths_bin, + [ + "git", + "allowed-signers", + "--repo", + env["AUTHS_HOME"], + "--output", + str(signers_file), + ], + env=env, + ).assert_success() + return signers_file + + @pytest.mark.requires_binary class TestGitSigning: @pytest.fixture(autouse=True) @@ -22,6 +43,8 @@ def test_sign_commit_roundtrip( sha = make_commit(git_repo, "signed commit", init_identity) assert len(sha) == 40 + _generate_allowed_signers(auths_bin, git_repo, init_identity) + result = run_auths( auths_bin, ["verify", sha], cwd=git_repo, env=init_identity ) @@ -31,12 +54,14 @@ def test_sign_commit_roundtrip( def test_verify_unsigned_commit(self, auths_bin, init_identity, git_repo): sha = make_commit(git_repo, "unsigned commit", init_identity) + + _generate_allowed_signers(auths_bin, git_repo, init_identity) + result = run_auths( auths_bin, ["verify", sha], cwd=git_repo, env=init_identity ) # Unsigned commit should report as unverified if result.returncode == 0: - # GAP: verify may succeed but with a warning pass else: result.assert_failure() @@ -51,6 +76,8 @@ def test_sign_and_verify_multiple_commits( sha = make_commit(git_repo, f"commit {i}", init_identity) shas.append(sha) + _generate_allowed_signers(auths_bin, git_repo, init_identity) + for sha in shas: result = run_auths( auths_bin, ["verify", sha], cwd=git_repo, env=init_identity @@ -66,7 +93,7 @@ def test_auths_sign_binary_direct( result = run_auths( auths_sign_bin, - ["-Y", "sign", "-n", "git", "-f", "auths:default", str(data_file)], + ["-Y", "sign", "-n", "git", "-f", "auths:main", str(data_file)], env=init_identity, ) if result.returncode != 0: @@ -85,7 +112,7 @@ def test_allowed_signers_generation( "git", "allowed-signers", "--repo", - str(git_repo), + init_identity["AUTHS_HOME"], "--output", str(signers_file), ], diff --git a/tests/e2e/test_identity_lifecycle.py b/tests/e2e/test_identity_lifecycle.py index a551b8d1..e91e2931 100644 --- a/tests/e2e/test_identity_lifecycle.py +++ b/tests/e2e/test_identity_lifecycle.py @@ -68,17 +68,26 @@ def test_id_show_json(self, auths_bin, init_identity): result = run_auths(auths_bin, ["id", "show", "--json"], env=init_identity) if result.returncode == 0 and result.stdout.strip().startswith("{"): data = json.loads(result.stdout) - if "did" in data: - assert_did_format(data["did"]) + controller_did = data.get("data", {}).get("controller_did") + if controller_did: + assert_did_format(controller_did) else: - # GAP: `auths id show --json` may not be implemented pytest.skip("auths id show --json not supported") def test_id_export_bundle(self, auths_bin, init_identity, tmp_path): bundle_path = tmp_path / "bundle.json" result = run_auths( auths_bin, - ["id", "export-bundle", "--output", str(bundle_path)], + [ + "id", + "export-bundle", + "--alias", + "main", + "--output", + str(bundle_path), + "--max-age-secs", + "3600", + ], env=init_identity, ) if result.returncode == 0: @@ -86,7 +95,6 @@ def test_id_export_bundle(self, auths_bin, init_identity, tmp_path): data = json.loads(bundle_path.read_text()) assert isinstance(data, dict) else: - # GAP: export-bundle may not be implemented pytest.skip("auths id export-bundle not supported") @pytest.mark.slow @@ -110,7 +118,16 @@ def test_full_lifecycle(self, auths_bin, isolated_env, tmp_path): bundle_path = tmp_path / "bundle.json" export = run_auths( auths_bin, - ["id", "export-bundle", "--output", str(bundle_path)], + [ + "id", + "export-bundle", + "--alias", + "main", + "--output", + str(bundle_path), + "--max-age-secs", + "3600", + ], env=isolated_env, ) if export.returncode != 0: diff --git a/tests/e2e/test_key_rotation.py b/tests/e2e/test_key_rotation.py index 52feec6b..d18e842b 100644 --- a/tests/e2e/test_key_rotation.py +++ b/tests/e2e/test_key_rotation.py @@ -1,16 +1,55 @@ """E2E tests for key rotation and revocation flows.""" +from pathlib import Path + import pytest -from helpers.cli import run_auths +from helpers.cli import get_device_did, run_auths from helpers.git import configure_signing, make_commit +def _generate_allowed_signers(auths_bin, git_repo: Path, env: dict) -> Path: + """Generate allowed-signers file inside the git repo's .auths/ dir.""" + auths_dir = git_repo / ".auths" + auths_dir.mkdir(exist_ok=True) + signers_file = auths_dir / "allowed_signers" + run_auths( + auths_bin, + [ + "git", + "allowed-signers", + "--repo", + env["AUTHS_HOME"], + "--output", + str(signers_file), + ], + env=env, + ).assert_success() + return signers_file + + +def _link_device(auths_bin, env, *, capabilities=None): + """Link a device and return the CLI result.""" + did = get_device_did(auths_bin, env) + args = [ + "device", + "link", + "--identity-key-alias", + "main", + "--device-key-alias", + "main", + "--device-did", + did, + ] + if capabilities: + args += ["--capabilities", capabilities] + return run_auths(auths_bin, args, env=env) + + @pytest.mark.slow @pytest.mark.requires_binary class TestKeyRotation: def test_rotate_keys(self, auths_bin, init_identity): - # Get identity DID before rotation id_before = run_auths(auths_bin, ["id", "show"], env=init_identity) id_before.assert_success() @@ -19,7 +58,6 @@ def test_rotate_keys(self, auths_bin, init_identity): pytest.skip(f"id rotate not available: {result.stderr}") result.assert_success() - # Verify DID unchanged after rotation id_after = run_auths(auths_bin, ["id", "show"], env=init_identity) id_after.assert_success() @@ -35,7 +73,8 @@ def test_verify_old_commit_after_rotation( sha_b = make_commit(git_repo, "after rotation", init_identity) - # Old commit should still verify (pre-rotation commitment) + _generate_allowed_signers(auths_bin, git_repo, init_identity) + verify_a = run_auths( auths_bin, ["verify", sha_a], cwd=git_repo, env=init_identity ) @@ -43,51 +82,45 @@ def test_verify_old_commit_after_rotation( pytest.skip(f"verify not available: {verify_a.stderr}") def test_emergency_freeze(self, auths_bin, init_identity): - result = run_auths(auths_bin, ["emergency", "freeze"], env=init_identity) + result = run_auths( + auths_bin, ["emergency", "freeze", "--yes"], env=init_identity + ) if result.returncode != 0: pytest.skip(f"emergency freeze not available: {result.stderr}") result.assert_success() - # Operations should fail when frozen status = run_auths(auths_bin, ["status"], env=init_identity) - # The system should indicate frozen state somehow assert status.returncode in (0, 1) def test_emergency_unfreeze(self, auths_bin, init_identity): - freeze = run_auths(auths_bin, ["emergency", "freeze"], env=init_identity) + freeze = run_auths( + auths_bin, ["emergency", "freeze", "--yes"], env=init_identity + ) if freeze.returncode != 0: pytest.skip("emergency freeze not available") unfreeze = run_auths( - auths_bin, ["emergency", "unfreeze"], env=init_identity + auths_bin, ["emergency", "unfreeze", "--yes"], env=init_identity ) if unfreeze.returncode != 0: pytest.skip(f"emergency unfreeze not available: {unfreeze.stderr}") unfreeze.assert_success() def test_rotate_preserves_attestations(self, auths_bin, init_identity): - # Create attestation before rotation - attest = run_auths( - auths_bin, - [ - "attest", - "--subject", - "did:key:z6MkRotateTest", - "--capabilities", - "sign:commit", - ], - env=init_identity, + link = _link_device( + auths_bin, init_identity, capabilities="sign:commit" ) - if attest.returncode != 0: - pytest.skip("attest not available") + if link.returncode != 0: + pytest.skip("device link not available") - # Rotate rotate = run_auths(auths_bin, ["id", "rotate"], env=init_identity) if rotate.returncode != 0: pytest.skip("id rotate not available") - # Attestation chain should still validate - verify = run_auths( - auths_bin, ["device", "verify"], env=init_identity + # After rotation, the device should still be listed + list_result = run_auths( + auths_bin, ["device", "list"], env=init_identity ) - assert verify.returncode in (0, 1) + list_result.assert_success() + did = get_device_did(auths_bin, init_identity) + assert "did:key:" in did diff --git a/tests/e2e/test_policy_engine.py b/tests/e2e/test_policy_engine.py index 104bf2b6..0465ff56 100644 --- a/tests/e2e/test_policy_engine.py +++ b/tests/e2e/test_policy_engine.py @@ -7,25 +7,37 @@ from helpers.cli import run_auths SIMPLE_POLICY = { - "and": [ - {"has_capability": "sign:commit"}, - {"not_expired": True}, - ] + "op": "And", + "args": [ + {"op": "HasCapability", "args": "sign:commit"}, + {"op": "NotExpired"}, + ], } COMPLEX_POLICY = { - "or": [ - {"and": [{"is_agent": True}, {"has_capability": "sign:commit"}]}, + "op": "Or", + "args": [ { - "and": [ - {"is_workload": True}, - {"has_capability": "deploy:staging"}, - ] + "op": "And", + "args": [ + {"op": "HasCapability", "args": "sign:commit"}, + {"op": "NotRevoked"}, + ], }, - ] + { + "op": "And", + "args": [ + {"op": "HasCapability", "args": "deploy:staging"}, + {"op": "NotExpired"}, + ], + }, + ], } -INVALID_POLICY = {"unknown_operator": True} +INVALID_POLICY = {"op": "UnknownOp"} + +TEST_ISSUER = "did:keri:ETestIssuer123456789012345678901234567890ab" +TEST_SUBJECT = "did:keri:ETestSubject12345678901234567890123456789ab" @pytest.mark.requires_binary @@ -52,10 +64,7 @@ def test_policy_lint_invalid(self, auths_bin, isolated_env, tmp_path): ["policy", "lint", str(policy_file)], env=isolated_env, ) - if "lint" in result.stderr.lower() or result.returncode != 0: - result.assert_failure() - else: - pytest.skip("policy lint may not validate JSON syntax") + result.assert_failure() def test_policy_compile_valid(self, auths_bin, isolated_env, tmp_path): policy_file = tmp_path / "policy.json" @@ -78,10 +87,11 @@ def test_policy_explain(self, auths_bin, isolated_env, tmp_path): context_file.write_text( json.dumps( { + "issuer": TEST_ISSUER, + "subject": TEST_SUBJECT, "capabilities": ["sign:commit"], - "is_agent": True, - "is_expired": False, - "is_revoked": False, + "revoked": False, + "expires_at": "2099-12-31T23:59:59Z", } ) ) @@ -100,7 +110,8 @@ def test_policy_explain(self, auths_bin, isolated_env, tmp_path): if result.returncode != 0: pytest.skip(f"policy explain not available: {result.stderr}") result.assert_success() - assert "allow" in result.stdout.lower() or "deny" in result.stdout.lower() + output = (result.stdout + result.stderr).lower() + assert "allow" in output or "deny" in output def test_policy_test_passing(self, auths_bin, isolated_env, tmp_path): policy_file = tmp_path / "policy.json" @@ -111,12 +122,15 @@ def test_policy_test_passing(self, auths_bin, isolated_env, tmp_path): json.dumps( [ { - "description": "agent with sign:commit should pass", + "name": "agent with sign:commit should pass", "context": { + "issuer": TEST_ISSUER, + "subject": TEST_SUBJECT, "capabilities": ["sign:commit"], - "is_expired": False, + "revoked": False, + "expires_at": "2099-12-31T23:59:59Z", }, - "expected": "allow", + "expect": "Allow", } ] ) @@ -140,12 +154,15 @@ def test_policy_test_failing(self, auths_bin, isolated_env, tmp_path): json.dumps( [ { - "description": "should fail - wrong expected", + "name": "should fail - wrong expected", "context": { + "issuer": TEST_ISSUER, + "subject": TEST_SUBJECT, "capabilities": ["sign:commit"], - "is_expired": False, + "revoked": False, + "expires_at": "2099-12-31T23:59:59Z", }, - "expected": "deny", + "expect": "Deny", } ] ) @@ -156,10 +173,7 @@ def test_policy_test_failing(self, auths_bin, isolated_env, tmp_path): ["policy", "test", str(policy_file), "--tests", str(tests_file)], env=isolated_env, ) - if "test" not in result.stderr.lower() and result.returncode == 0: - pytest.skip("policy test may not detect mismatches") - else: - result.assert_failure() + result.assert_failure() def test_policy_diff(self, auths_bin, isolated_env, tmp_path): old_policy = tmp_path / "old.json"