From 532860fb550f56eca4b7920accae13c1e85804c2 Mon Sep 17 00:00:00 2001 From: bordumb Date: Wed, 11 Mar 2026 16:04:04 +0000 Subject: [PATCH 1/2] feat(auths-verifier): add DidParseError, parse(), from_prefix(), FromStr for DID types (fn-62.1) Add validation infrastructure to DeviceDID and IdentityDID without breaking existing code. DeviceDID::parse() validates strict did:key:z prefix, IdentityDID::parse() validates strict did:keri: prefix. Adds FromStr impls, IdentityDID::from_prefix() and prefix() helpers ported from ValidatedIdentityDID. --- crates/auths-verifier/src/lib.rs | 4 +- crates/auths-verifier/src/types.rs | 101 +- .../auths-verifier/tests/cases/did_parsing.rs | 185 ++++ crates/auths-verifier/tests/cases/mod.rs | 1 + docs/plans/launch_cleaning.md | 462 +++++++++ docs/plans/sans_io_spec.md | 911 ------------------ docs/plans/typing_cleaning.md | 349 +++++++ 7 files changed, 1099 insertions(+), 914 deletions(-) create mode 100644 crates/auths-verifier/tests/cases/did_parsing.rs create mode 100644 docs/plans/launch_cleaning.md delete mode 100644 docs/plans/sans_io_spec.md create mode 100644 docs/plans/typing_cleaning.md diff --git a/crates/auths-verifier/src/lib.rs b/crates/auths-verifier/src/lib.rs index 39588869..023a525b 100644 --- a/crates/auths-verifier/src/lib.rs +++ b/crates/auths-verifier/src/lib.rs @@ -69,8 +69,8 @@ pub mod witness; // Re-export verification types for convenience pub use types::{ - ChainLink, DeviceDID, DidConversionError, IdentityDID, VerificationReport, VerificationStatus, - signer_hex_to_did, validate_did, + ChainLink, DeviceDID, DidConversionError, DidParseError, IdentityDID, VerificationReport, + VerificationStatus, signer_hex_to_did, validate_did, }; // Re-export action envelope diff --git a/crates/auths-verifier/src/types.rs b/crates/auths-verifier/src/types.rs index 5399eb6b..c5891e85 100644 --- a/crates/auths-verifier/src/types.rs +++ b/crates/auths-verifier/src/types.rs @@ -126,6 +126,7 @@ impl ChainLink { use std::borrow::Borrow; use std::fmt; use std::ops::Deref; +use std::str::FromStr; // ============================================================================ // IdentityDID Type @@ -156,6 +157,55 @@ impl IdentityDID { Self(s) } + /// Validates and parses a `did:keri:` string into an `IdentityDID`. + /// + /// Args: + /// * `s`: A DID string that must start with `did:keri:` followed by a non-empty KERI prefix. + /// + /// Usage: + /// ```rust + /// # use auths_verifier::IdentityDID; + /// let did = IdentityDID::parse("did:keri:EPrefix123").unwrap(); + /// assert_eq!(did.as_str(), "did:keri:EPrefix123"); + /// ``` + pub fn parse(s: &str) -> Result { + match s.strip_prefix("did:keri:") { + Some("") => Err(DidParseError::EmptyIdentifier), + Some(_) => Ok(Self(s.to_string())), + None => Err(DidParseError::InvalidIdentityPrefix(s.to_string())), + } + } + + /// Builds an `IdentityDID` from a raw KERI prefix string. + /// + /// Args: + /// * `prefix`: The KERI prefix without the `did:keri:` scheme (e.g., `"EOrg123"`). + /// + /// Usage: + /// ```rust + /// # use auths_verifier::IdentityDID; + /// let did = IdentityDID::from_prefix("EOrg123").unwrap(); + /// assert_eq!(did.as_str(), "did:keri:EOrg123"); + /// ``` + pub fn from_prefix(prefix: &str) -> Result { + if prefix.is_empty() { + return Err(DidParseError::EmptyIdentifier); + } + Ok(Self(format!("did:keri:{}", prefix))) + } + + /// Returns the KERI prefix portion of the DID (after `did:keri:`). + /// + /// Usage: + /// ```rust + /// # use auths_verifier::IdentityDID; + /// let did = IdentityDID::parse("did:keri:EOrg123").unwrap(); + /// assert_eq!(did.prefix(), "EOrg123"); + /// ``` + pub fn prefix(&self) -> &str { + self.0.strip_prefix("did:keri:").unwrap_or(&self.0) + } + /// Returns the DID as a string slice. pub fn as_str(&self) -> &str { &self.0 @@ -173,6 +223,14 @@ impl fmt::Display for IdentityDID { } } +impl FromStr for IdentityDID { + type Err = DidParseError; + + fn from_str(s: &str) -> Result { + Self::parse(s) + } +} + impl From<&str> for IdentityDID { fn from(s: &str) -> Self { Self(s.to_string()) @@ -250,6 +308,25 @@ impl DeviceDID { DeviceDID(s.into()) } + /// Validates and parses a `did:key:z` string into a `DeviceDID`. + /// + /// Args: + /// * `s`: A DID string that must start with `did:key:z` followed by non-empty base58 content. + /// + /// Usage: + /// ```rust + /// # use auths_verifier::DeviceDID; + /// let did = DeviceDID::parse("did:key:z6MkhaXgBZDvotDkL5257faiztiGiC2QtKLGpbnnEGta2doK").unwrap(); + /// assert_eq!(did.as_str(), "did:key:z6MkhaXgBZDvotDkL5257faiztiGiC2QtKLGpbnnEGta2doK"); + /// ``` + pub fn parse(s: &str) -> Result { + match s.strip_prefix("did:key:z") { + Some("") => Err(DidParseError::EmptyIdentifier), + Some(_) => Ok(Self(s.to_string())), + None => Err(DidParseError::InvalidDevicePrefix(s.to_string())), + } + } + /// Constructs a `did:key:z...` identifier from a 32-byte Ed25519 public key. /// /// This uses the multicodec prefix for Ed25519 (0xED 0x01) and encodes it with base58btc. @@ -291,13 +368,20 @@ impl DeviceDID { } } -// Allow `.to_string()` and printing impl fmt::Display for DeviceDID { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { self.0.fmt(f) } } +impl FromStr for DeviceDID { + type Err = DidParseError; + + fn from_str(s: &str) -> Result { + Self::parse(s) + } +} + // Allow `DeviceDID::from("did:key:abc")` and vice versa impl From<&str> for DeviceDID { fn from(s: &str) -> Self { @@ -365,6 +449,21 @@ pub enum DidConversionError { WrongKeyLength(usize), } +/// Errors from DID string parsing and validation. +#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] +#[non_exhaustive] +pub enum DidParseError { + /// DeviceDID must start with `did:key:z`. + #[error("DeviceDID must start with 'did:key:z', got: {0}")] + InvalidDevicePrefix(String), + /// IdentityDID must start with `did:keri:`. + #[error("IdentityDID must start with 'did:keri:', got: {0}")] + InvalidIdentityPrefix(String), + /// The method-specific identifier portion is empty. + #[error("DID method-specific identifier is empty")] + EmptyIdentifier, +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/auths-verifier/tests/cases/did_parsing.rs b/crates/auths-verifier/tests/cases/did_parsing.rs new file mode 100644 index 00000000..7f57b4a4 --- /dev/null +++ b/crates/auths-verifier/tests/cases/did_parsing.rs @@ -0,0 +1,185 @@ +use auths_verifier::{DeviceDID, DidParseError, IdentityDID}; + +// ============================================================================ +// DeviceDID::parse() +// ============================================================================ + +#[test] +fn device_did_parse_valid_did_key_z() { + let did = DeviceDID::parse("did:key:z6MkhaXgBZDvotDkL5257faiztiGiC2QtKLGpbnnEGta2doK").unwrap(); + assert_eq!( + did.as_str(), + "did:key:z6MkhaXgBZDvotDkL5257faiztiGiC2QtKLGpbnnEGta2doK" + ); +} + +#[test] +fn device_did_parse_minimal_valid() { + let did = DeviceDID::parse("did:key:zA").unwrap(); + assert_eq!(did.as_str(), "did:key:zA"); +} + +#[test] +fn device_did_parse_rejects_wrong_multibase_prefix() { + let err = DeviceDID::parse("did:key:fOtherBase").unwrap_err(); + assert!(matches!(err, DidParseError::InvalidDevicePrefix(_))); +} + +#[test] +fn device_did_parse_rejects_empty_after_z() { + let err = DeviceDID::parse("did:key:z").unwrap_err(); + assert!(matches!(err, DidParseError::EmptyIdentifier)); +} + +#[test] +fn device_did_parse_rejects_empty_did_key() { + let err = DeviceDID::parse("did:key:").unwrap_err(); + assert!(matches!(err, DidParseError::InvalidDevicePrefix(_))); +} + +#[test] +fn device_did_parse_rejects_wrong_scheme() { + let err = DeviceDID::parse("did:keri:EPrefix").unwrap_err(); + assert!(matches!(err, DidParseError::InvalidDevicePrefix(_))); +} + +#[test] +fn device_did_parse_rejects_garbage() { + let err = DeviceDID::parse("garbage").unwrap_err(); + assert!(matches!(err, DidParseError::InvalidDevicePrefix(_))); +} + +#[test] +fn device_did_parse_rejects_empty_string() { + let err = DeviceDID::parse("").unwrap_err(); + assert!(matches!(err, DidParseError::InvalidDevicePrefix(_))); +} + +// ============================================================================ +// IdentityDID::parse() +// ============================================================================ + +#[test] +fn identity_did_parse_valid_did_keri() { + let did = IdentityDID::parse("did:keri:EPrefix123").unwrap(); + assert_eq!(did.as_str(), "did:keri:EPrefix123"); +} + +#[test] +fn identity_did_parse_rejects_did_key() { + let err = IdentityDID::parse("did:key:z6MkValid").unwrap_err(); + assert!(matches!(err, DidParseError::InvalidIdentityPrefix(_))); +} + +#[test] +fn identity_did_parse_rejects_empty_keri_prefix() { + let err = IdentityDID::parse("did:keri:").unwrap_err(); + assert!(matches!(err, DidParseError::EmptyIdentifier)); +} + +#[test] +fn identity_did_parse_rejects_garbage() { + let err = IdentityDID::parse("garbage").unwrap_err(); + assert!(matches!(err, DidParseError::InvalidIdentityPrefix(_))); +} + +#[test] +fn identity_did_parse_rejects_empty_string() { + let err = IdentityDID::parse("").unwrap_err(); + assert!(matches!(err, DidParseError::InvalidIdentityPrefix(_))); +} + +// ============================================================================ +// IdentityDID::from_prefix() and prefix() +// ============================================================================ + +#[test] +fn identity_did_from_prefix_builds_correct_did() { + let did = IdentityDID::from_prefix("EOrg123").unwrap(); + assert_eq!(did.as_str(), "did:keri:EOrg123"); +} + +#[test] +fn identity_did_from_prefix_rejects_empty() { + let err = IdentityDID::from_prefix("").unwrap_err(); + assert!(matches!(err, DidParseError::EmptyIdentifier)); +} + +#[test] +fn identity_did_prefix_returns_keri_portion() { + let did = IdentityDID::parse("did:keri:EOrg123").unwrap(); + assert_eq!(did.prefix(), "EOrg123"); +} + +#[test] +fn identity_did_from_prefix_roundtrips_through_prefix() { + let did = IdentityDID::from_prefix("ETestPrefix").unwrap(); + assert_eq!(did.prefix(), "ETestPrefix"); +} + +// ============================================================================ +// FromStr +// ============================================================================ + +#[test] +fn device_did_fromstr_works() { + let did: DeviceDID = "did:key:z6MkTest".parse().unwrap(); + assert_eq!(did.as_str(), "did:key:z6MkTest"); +} + +#[test] +fn device_did_fromstr_rejects_invalid() { + let err = "garbage".parse::().unwrap_err(); + assert!(matches!(err, DidParseError::InvalidDevicePrefix(_))); +} + +#[test] +fn identity_did_fromstr_works() { + let did: IdentityDID = "did:keri:ETest".parse().unwrap(); + assert_eq!(did.as_str(), "did:keri:ETest"); +} + +#[test] +fn identity_did_fromstr_rejects_invalid() { + let err = "did:key:z6MkTest".parse::().unwrap_err(); + assert!(matches!(err, DidParseError::InvalidIdentityPrefix(_))); +} + +// ============================================================================ +// Display round-trips through FromStr +// ============================================================================ + +#[test] +fn device_did_display_roundtrips_through_fromstr() { + let original = + DeviceDID::parse("did:key:z6MkhaXgBZDvotDkL5257faiztiGiC2QtKLGpbnnEGta2doK").unwrap(); + let displayed = original.to_string(); + let parsed: DeviceDID = displayed.parse().unwrap(); + assert_eq!(original, parsed); +} + +#[test] +fn identity_did_display_roundtrips_through_fromstr() { + let original = IdentityDID::parse("did:keri:EOrg123").unwrap(); + let displayed = original.to_string(); + let parsed: IdentityDID = displayed.parse().unwrap(); + assert_eq!(original, parsed); +} + +// ============================================================================ +// Error messages +// ============================================================================ + +#[test] +fn did_parse_error_display_is_useful() { + let err = DidParseError::InvalidDevicePrefix("bad".to_string()); + assert!(err.to_string().contains("did:key:z")); + assert!(err.to_string().contains("bad")); + + let err = DidParseError::InvalidIdentityPrefix("bad".to_string()); + assert!(err.to_string().contains("did:keri:")); + assert!(err.to_string().contains("bad")); + + let err = DidParseError::EmptyIdentifier; + assert!(err.to_string().contains("empty")); +} diff --git a/crates/auths-verifier/tests/cases/mod.rs b/crates/auths-verifier/tests/cases/mod.rs index 97ace0da..f6e7e31d 100644 --- a/crates/auths-verifier/tests/cases/mod.rs +++ b/crates/auths-verifier/tests/cases/mod.rs @@ -1,5 +1,6 @@ mod capability_fromstr; mod commit_verify; +mod did_parsing; mod expiration_skew; #[cfg(feature = "ffi")] mod ffi_smoke; diff --git a/docs/plans/launch_cleaning.md b/docs/plans/launch_cleaning.md new file mode 100644 index 00000000..08df5694 --- /dev/null +++ b/docs/plans/launch_cleaning.md @@ -0,0 +1,462 @@ +# Auths Codebase Review +**Version:** `0.0.1-rc.13` · **Lines of code:** ~121K · **Crates:** 22 +**Date:** 2026-03-11 + +--- + +## Section 1: Code Quality Review + +### 1.1 Architecture & Layering + +**Verdict: Mostly sound with notable leakage.** + +The SDK-first design is clearly intentional and mostly respected. `auths-sdk/src/workflows/` is where the real logic lives — signing, rotation, provisioning, audit, etc. — and the CLI delegates to those workflows through a dependency-injected `AuthsContext`. The `ExecutableCommand` trait is implemented consistently across every top-level command (including `WhoamiCommand`, `WitnessCommand`, and the newer commands like `RegistryOverrides`). Port traits in `auths-core/src/ports/` and `auths-sdk/src/ports/` are well-designed and free of implementation leakage. + +**Specific issues:** + +- **`Utc::now()` called directly throughout CLI command handlers** (not just entry points). Examples: `commands/id/identity.rs:9787`, `commands/device/pair/common.rs:5774`, `commands/emergency.rs:8193,8341,8355`, `commands/org.rs:14352,14416,14540,14621,14677,14687`. The `ClockProvider` port exists, and there is even a workspace lint banning `Utc::now()` in the SDK layers, but the CLI commands are exempt from this lint and call `Utc::now()` directly. This makes those code paths untestable without real time passing. + +- **`commands/scim.rs:16435` spawns `auths-scim-server` as a child process** without any path validation. The binary name is hardcoded as a bare string. If `auths-scim-server` is not on `PATH`, the error message "Is it installed?" is the only guidance — no path, no suggestion to install via `cargo install`. This is a presentation-layer concern that is fine to keep in the CLI, but the spawn has no timeout and `child.wait()` will block indefinitely. + +- **Business logic in CLI commands that should be in the SDK:** `commands/id/migrate.rs` (~1300 lines) contains substantial identity migration orchestration. `commands/device/pair/common.rs` builds pairing state machines inline. These would benefit from extraction into `auths-sdk/src/workflows/`. + +--- + +### 1.2 Type Safety + +**Verdict: Good foundational work; a gap in the domain boundary.** + +Newtypes exist for the right things: +- `DeviceDID(pub String)` — `auths-verifier/src/types.rs:110819` +- `IdentityDID(pub String)` — `auths-verifier/src/types.rs:110720` +- `KeyAlias(String)` — `auths-core:32796` +- `EmailAddress(String)` — `auths-sdk:92064` +- `Ed25519PublicKey([u8; 32])` and `Ed25519Signature([u8; 64])` — `auths-verifier/src/types.rs` + +**Specific issues:** + +- **Raw `String` used at CLI-to-SDK boundaries for domain types.** In `commands/device/pair/common.rs` and throughout `commands/id/`: + ``` + device_did: String (line 4876) + identity_key_alias: String (line 4862) + controller_did: String (line 9204) + ``` + These fields cross module boundaries without being wrapped in their newtypes. This means validation is deferred past the point where it is cheapest to catch. + +- **`pub` fields on `DeviceDID` and `IdentityDID`** — `DeviceDID(pub String)`. The inner `String` should be private, forcing construction through a validated `::new()` or `::parse()` that checks DID format at creation time. + +- **`AgentSigningAdapter` in `auths-cli/src/adapters/agent.rs:608,612`** — stores `key_alias: String` directly instead of `KeyAlias`. The newtype exists but is not used here. + +--- + +### 1.3 DRY / Duplication + +**Three independent implementations of `expand_tilde`:** + +| Location | Signature | Error type | +|---|---|---| +| `auths-cli/src/commands/git.rs:8977` | `pub(crate) fn expand_tilde(path: &Path) -> Result` | `anyhow::Error` | +| `auths-cli/src/commands/witness.rs:19704` | `fn expand_tilde(path: &std::path::Path) -> Result` | `anyhow::Error` | +| `auths-storage/src/git/config.rs:56667` | `fn expand_tilde(path: &std::path::Path) -> Result` | `StorageError` | + +The first two are byte-for-byte identical. The third is identical in logic but returns a different error type. There is a natural home for this in `auths-core` or a `auths-cli/src/core/fs.rs` utility (the `core/fs.rs` file already exists but does not contain `expand_tilde`). This is a pre-launch cleanup item. + +**Other duplication:** +- `generate_token_b64()` appears to be defined separately in `commands/scim.rs` and potentially other places — warrants audit. +- `mask_url()` in `commands/scim.rs` is a one-off utility with no shared home. +- JSON response helper `JsonResponse` with `.error()` and `.success()` constructors exists alongside raw `serde_json::json!` construction in several command files. + +--- + +### 1.4 Error Handling + +**Verdict: Structurally good; a class of opaque `String` variants undermines the discipline.** + +The `AuthsErrorInfo` trait (providing `error_code()` and `suggestion()`) is implemented on `AgentError`, `TrustError`, `SetupError`, `DeviceError`, and `AttestationError`. The layering of `anyhow` at the CLI and `thiserror` in the SDK/core is respected — no `anyhow` was found leaking into `auths-sdk`, `auths-id`, `auths-core`, or `auths-verifier`. + +**Specific issues:** + +- **Opaque `String` error variants** that lose structure and prevent `AuthsErrorInfo` from providing specific codes/suggestions: + ``` + auths-core: SecurityError(String), CryptoError(String), SigningFailed(String) + StorageError(String), GitError(String), InvalidInput(String), Proto(String) + (lines 27259–27295) + auths-sdk: StorageError(String), SigningFailed(String) (lines 87350, 88263) + auths-verifier: InvalidInput(String), CryptoError(String) (lines 107893, 107897) + ``` + Each of these should be a structured variant (e.g., `CryptoError { operation: &'static str, source: ring::error::Unspecified }`) so that `error_code()` can return a stable, documentable string. + +- **`MutexError(String)` at `auths-core:27291`** — mutex poisoning is a programming error, not a user-facing error. It should panic or be mapped to an internal error code, not propagate a `String` to the user. + +- **Errors in `commands/audit.rs` and `commands/org.rs` at lines 4105, 10234, 10332, 10627, 10736** construct JSON with `"created_at": chrono::Utc::now()` inline, mixing side-effectful timestamp generation into serialisation paths. + +--- + +### 1.5 Testing + +**Verdict: Unusually thorough for a solo project; two structural gaps.** + +1,389 unit tests found across the workspace. Fakes exist for all core port traits: `FakeConfigStore`, `FakeAttestationSink`, `FakeAttestationSource`, `FakeIdentityStorage`, `FakeRegistryBackend` (in `auths-id`), plus `FakeAgent`, `FakeAllowedSignersStore`, `FakeGit`, `FakeGitConfig`, `FakeSigner` (in `auths-sdk/src/testing/fakes/`). Contract tests in `auths-sdk/src/testing/contracts/` provide a shared test suite for adapters. + +Fuzz targets exist for `attestation_parse`, `did_parse`, and `verify_chain` in `auths-verifier/fuzz/`. + +**Gaps:** + +1. **CLI integration test coverage is thin — only ~50 lines** use `assert_cmd`/`Command::cargo_bin`. For a tool whose primary surface is a CLI, there should be end-to-end tests for at minimum `init`, `sign`, `verify`, `doctor`, and `device pair`. The happy path for the core user journey (`init` → `git commit` → `verify HEAD`) does not appear to have an integration test. + +2. **`Utc::now()` called directly in ~35 CLI command sites** (detailed above). Because these are not injected through `ClockProvider`, time-sensitive logic (expiry checks, token freshness, freeze state) cannot be tested deterministically without mocking system time. This is the most significant testability gap. + +--- + +### 1.6 Security + +**Verdict: Strong fundamentals; three areas need hardening before public launch.** + +**What's working well:** +- `Zeroizing` and `ZeroizeOnDrop` are used consistently on `SecureSeed`, `Ed25519Keypair.secret_key_bytes`, and X25519 shared secrets. +- `validate_passphrase()` validates at the boundary. +- The KEL validation chain in `auths-id/src/keri/validate.rs` calls `verify_event_said()`, `verify_sequence()`, `verify_chain_linkage()`, and `verify_event_signature()` — the full chain is cryptographically verified, not just structurally present. Ed25519 signatures are verified with `ring` (`pk.verify()` at line `53210`). +- `auths-verifier` has fuzz targets. + +**Issues requiring attention before launch:** + +**P0 — `verify-options` pass-through in `auths-sign`:** +In `bin/sign.rs`, `args.verify_options` (a `Vec` populated from CLI `--verify-option` flags) is passed directly as arguments to `ssh-keygen` via `.arg("-O").arg(opt)` (lines ~198–199 and ~230–231). While `Command::new` with explicit `.arg()` calls is not shell injection, a crafted `-O` value like `no-touch-required` or a future `ssh-keygen` flag could alter verification semantics. These options should be validated against an allowlist of known-safe `verify-time=` patterns before being passed through. This binary is callable from CI environments with attacker-influenced inputs. + +**P1 — `DeviceDID` and `IdentityDID` inner values are publicly accessible:** +`DeviceDID(pub String)` and `IdentityDID(pub String)` can be constructed with arbitrary strings without parsing. The DID format (`did:keri:...`) is not validated at construction. A malformed DID that bypasses newtypes can reach storage and the KEL resolver. + +**P2 — `commands/emergency.rs:8341` writes `frozen_at: chrono::Utc::now()` into a freeze record:** +This timestamp is written to the git ref store and is later used to compute `expires_description()`. Since the clock is not injected, replay or time-skew attacks on freeze state cannot be tested. This is lower severity but relevant for enterprise audit trail integrity. + +--- + +## Section 2: v0.1.0 Launch Readiness + +### Feature Completeness + +The core end-to-end user journey is implemented: +- `auths init` — guided setup with SSH agent integration, key generation, git config. +- `git commit` → signed via `auths-sign` git-config hook. +- `auths verify HEAD` / `auths verify-commit` — full attestation chain verification. +- GitHub Action — working (per your confirmation). +- `auths doctor` — functional with fix suggestions. +- Device pairing — LAN, online relay, and offline QR modes implemented. +- Key rotation — `auths key rotate` with KEL append. +- Revocation — `auths emergency` with freeze/revoke semantics. + +### API Stability + +CLI flags are well-structured and consistently named. JSON output (`--json`) is present on the main verification paths. However, JSON schemas are generated by `xtask/src/gen_schema.rs` and appear to be in flux — the schema for attestation bundles and verification output should be frozen and versioned before public docs point to them. + +SDK public types in `auths-verifier` are the most stable — these are what the WASM widget, Python SDK, and Node SDK consume. `auths-sdk` public types are less stable and should not be documented as stable external API at v0.1.0. + +### Overall Readiness Rating: **7 / 10** + +Blockers before shipping: + +| # | Blocker | Severity | +|---|---|---| +| 1 | `verify-options` allowlist in `auths-sign` | P0 security | +| 2 | `DeviceDID`/`IdentityDID` with `pub` inner field — validate at construction | P0 type safety | +| 3 | End-to-end CLI integration test for core journey (`init` → `sign` → `verify`) | P0 launch confidence | +| 4 | `expand_tilde` triplicate — consolidate before adding a 4th | P1 DRY | +| 5 | `Utc::now()` in ~35 CLI command sites — at minimum the expiry and freeze paths need `ClockProvider` injection | P1 testability | +| 6 | Opaque `String` variants in error enums — replace the 10 identified with structured variants | P1 user experience | +| 7 | `commands/scim.rs` child process spawn — add timeout, better error message | P2 | +| 8 | JSON output schema versioning — freeze `--json` schemas before publishing docs | P2 | + +Items 1–3 are hard blocks. Items 4–6 are strong recommendations. Items 7–8 can be post-launch. + +--- + +## Section 3: Valuation & Product Strategy + +### 3.1 Current Fair Valuation + +**Range: $1.5M – $4M pre-money.** + +Rationale: + +- **Technical depth is real and rare.** A solo KERI-based cryptographic identity system in Rust with a working CLI, GitHub Action, WASM verifier, Python SDK, Node SDK, and multi-platform CI pipeline represents 6–12 months of senior engineering time minimum for a team. As a solo build over ~2.5 months with AI assistance, it demonstrates extraordinary execution velocity. +- **No revenue, no production users.** Pre-launch means no ARR multiple can be applied. +- **Comparable early-stage developer security tools** (Sigstore graduated into CNCF with Google/Purdue/Red Hat backing before it had revenue; Keybase raised at ~$10M with a working product but no clear business model). The comparable without institutional backing and without proven adoption is in the $1.5–4M range. +- **The KERI bet is a differentiator and a risk.** KERI is technically superior to X.509 for self-sovereign identity, but has almost no mainstream adoption. An investor will price in the education cost. +- **Upside scenario:** If the Hacker News launch generates measurable GitHub stars (>500), active users (>100 in first month), and PR integrations (even 2–3 notable repos), the valuation conversation shifts to $5–8M seed. + +--- + +### 3.2 Path to $50M Valuation + +$50M requires enterprise SaaS revenue or a clear path to it. Here is what needs to be true: + +**Revenue model ($50M = ~$5M ARR at 10x, or ~$3M ARR at 15x for a growing company):** + +- **Free tier:** Open source CLI, GitHub Action, WASM verifier, Python/Node SDKs. This is already the plan and is correct — developer adoption is the top of funnel. +- **Team tier ($29/user/month):** Managed witness infrastructure, org-level policy enforcement, audit log export (SOC 2 evidence), SAML/OIDC SSO, Slack/Teams alerts for signing anomalies. Target: engineering teams of 5–50. +- **Enterprise tier ($80–150/user/month or $50K–200K/year flat):** SCIM provisioning (already built!), self-hosted witness nodes, HSM integration, GitHub Enterprise + GitLab self-hosted connectors, SLA, priority support, CISO-friendly compliance exports (SLSA, SBOM attestation). Target: >500-engineer orgs with compliance mandates. +- **Infrastructure licensing ($500K+/year):** For financial services or defense contractors who cannot use SaaS — air-gapped deployment of the full Auths stack. + +At $3M ARR from 50 enterprise customers averaging $60K/year, with 15x multiple on growing SaaS, $50M is credible. + +**Market positioning:** + +| Competitor | Weakness Auths exploits | +|---|---| +| **Sigstore / Cosign** | Certificate-authority dependent (Fulcio), not self-sovereign, Google-run trust root that enterprises cannot audit-own | +| **GitHub's built-in signing** | Tied to GitHub, no portability to GitLab/self-hosted, no org-level enforcement policy, no revocation story | +| **GPG commit signing** | Horrible UX, key distribution nightmare, no rotation story, no device binding | +| **Keybase** (effectively dead) | Centralized servers, no cryptographic revocation, no enterprise features, abandoned | +| **SpruceID / DIDKit** | Broader W3C DID focus, not git-native, no developer UX story | + +Auths' moat is: **git-native storage + KERI-based self-sovereign rotation + developer UX that matches GPG simplicity without the GPG pain.** + +**Adoption metrics an investor needs to see before $50M:** +- 2,000+ GitHub stars +- 500+ weekly active CLI users (telemetry) +- 10+ enterprise pilots (even unpaid) +- 3+ notable open-source repositories with Auths CI verification in their workflows +- Published CVE or security audit report showing the protocol is sound + +**Team composition needed at $50M pitch:** +- 1 technical co-founder / CEO (you) +- 1 additional senior Rust engineer +- 1 developer advocate / growth engineer +- 1 enterprise sales / solutions engineer + +**Technical moat (what's hard to replicate):** +1. KERI-based KEL with cryptographic rotation — competitors would have to rebuild from protocol foundations. +2. The `auths-verifier` WASM module that verifies anywhere with no server dependency — this is genuinely unusual. +3. Git-native storage means zero infrastructure cost for the user in the free tier — no server to maintain. +4. Multi-platform SDK surface (Rust, Python, Node, WASM) built from a single source of truth. + +--- + +### 3.3 v1.0.0 Feature Requirements + +These are the features that separate "impressive developer tool" from "enterprise-mandatable infrastructure." + +--- + +#### Epic 1: Structured Error Codes and Actionable CLI Output +**Why it matters:** A CISO cannot mandate a tool their engineers curse at. Error messages must be searchable in docs. + +**Scope:** +- Replace all 10+ opaque `String` error variants identified in Section 1.4 with structured enum variants. Each variant must carry typed fields (not strings) and implement `AuthsErrorInfo` with a stable `error_code()` (e.g., `E1042`) and a `suggestion()` string pointing to a docs URL. +- Every error emitted by the CLI must have a unique, stable, documented error code. Format: `[AUTHS-EXXX]` prefixed in terminal output. +- Add a `auths error ` subcommand that prints the full explanation and resolution steps for a given error code — identical to how the Rust compiler handles `rustc --explain E0XXX`. +- Error codes must be included in JSON output (`--json` flag) so CI systems can programmatically handle specific failure modes. + +**Files to touch:** +- `crates/auths-core/src/error.rs` — replace `String` variants +- `crates/auths-sdk/src/ports/agent.rs`, `crates/auths-sdk/src/result.rs` +- `crates/auths-verifier/src/error.rs` +- `crates/auths-cli/src/commands/executable.rs` — add error code formatting to output +- New: `crates/auths-cli/src/commands/explain.rs` +- Docs: `docs/errors/` directory with one `.md` per error code + +--- + +#### Epic 2: `Utc::now()` Injection — Complete Clock Discipline +**Why it matters:** Every expiry check, freeze check, and token validity check in the CLI is currently untestable. This is a launch blocker for the freeze/revocation path and a pre-condition for writing meaningful integration tests. + +**Scope:** +- Audit all `Utc::now()` call sites in `auths-cli` (~35 identified). For each: + - If the call is in an `ExecutableCommand::execute()` entry point, it is acceptable to call `Utc::now()` once and pass the result down. + - If the call is more than one function call deep from the entry point, it must accept a `DateTime` parameter instead. +- Commands requiring specific attention: `emergency.rs` (freeze/revoke timestamps), `device/pair/common.rs` (paired_at, token expiry), `org.rs` (created_at, attestation expiry), `commands/id/identity.rs` (bundle_timestamp), `status.rs`. +- The workspace lint `{ path = "chrono::offset::Utc::now", reason = "inject ClockProvider..." }` already exists but exempts `auths-cli`. Remove the exemption and fix the resulting compilation errors. +- Update fakes: `auths-sdk/src/testing/fakes/` — add `FakeClock` (likely already partially exists given `ClockProvider` is in `auths-verifier/src/clock.rs`; confirm it is exposed in the testing module). + +**Files to touch:** +- `crates/auths-cli/src/commands/emergency.rs` +- `crates/auths-cli/src/commands/device/pair/common.rs` +- `crates/auths-cli/src/commands/org.rs` +- `crates/auths-cli/src/commands/id/identity.rs` +- `crates/auths-cli/src/commands/status.rs` +- `crates/auths-cli/src/commands/id/migrate.rs` +- `crates/auths-cli/src/commands/device/authorization.rs` +- `Workspace.toml` — remove `auths-cli` exemption from `disallowed-methods` lint + +--- + +#### Epic 3: CLI Integration Test Suite +**Why it matters:** With only ~50 lines of `assert_cmd` coverage across the entire CLI, you cannot confidently say the install-to-first-commit journey works on a clean machine. This is a launch confidence blocker. + +**Scope:** +- Write integration tests using `assert_cmd` + `tempfile` for the following scenarios. Each test must use a real temporary git repository and a real temporary `$HOME`-equivalent directory (no global state): + + 1. **`init` happy path** — `auths init --non-interactive` (or with scripted prompts) produces valid `~/.auths/` layout, sets `git config gpg.ssh.allowedSignersFile`, sets `git config gpg.format ssh`, sets `git config user.signingkey`. + 2. **`sign` + `verify` round trip** — after `init`, make a commit, run `auths verify HEAD`, assert exit 0 and JSON output contains `"status": "verified"`. + 3. **`doctor` detects misconfiguration** — remove `gpg.format` from git config, run `auths doctor`, assert it identifies the missing config and suggests a fix. + 4. **`key rotate` maintains verify** — rotate the signing key, make a new commit, verify both old and new commits pass (KEL replay). + 5. **`emergency revoke` blocks verify** — after revocation, `auths verify HEAD` on a pre-revocation commit must fail with a specific error code. + 6. **`--json` output schema** — assert that `auths verify HEAD --json` output is valid against the published JSON schema. + +- Each test must be runnable in CI without network access (use `FakeWitness` or disable witness requirement). +- Tests must be in `crates/auths-cli/tests/` using `assert_cmd::Command::cargo_bin("auths")`. +- Add a `Makefile` target or `xtask` subcommand `cargo xtask test-integration` that runs these with appropriate environment isolation. + +--- + +#### Epic 4: `expand_tilde` Consolidation and `auths-utils` Crate +**Why it matters:** Three implementations of the same function is a maintenance hazard. The right fix is a micro-crate or a shared module that all layers can depend on without introducing circular dependencies. + +**Scope:** +- Create `crates/auths-utils/` as a new zero-dependency crate (no `auths-*` dependencies, only `std` + `dirs`). +- Move `expand_tilde` into `auths-utils/src/path.rs` with signature `pub fn expand_tilde(path: &Path) -> Result` where `ExpandTildeError` is a `thiserror` enum with a single `HomeDirNotFound` variant. +- Replace the three existing implementations with `use auths_utils::path::expand_tilde`. +- Also move `mask_url()` (currently inlined in `commands/scim.rs`) into `auths-utils/src/url.rs`. +- Add `auths-utils` as a `workspace` dependency. +- The crate should be `publish = false` — it is an internal utility, not a public API surface. + +**Files to touch:** +- New: `crates/auths-utils/Cargo.toml`, `crates/auths-utils/src/lib.rs`, `crates/auths-utils/src/path.rs`, `crates/auths-utils/src/url.rs` +- `crates/auths-cli/src/commands/git.rs:8977` — delete `expand_tilde`, add `use auths_utils::path::expand_tilde` +- `crates/auths-cli/src/commands/witness.rs:19704` — same +- `crates/auths-storage/src/git/config.rs:56667` — delete `expand_tilde`, adapt error type +- `Cargo.toml` (workspace) — add `auths-utils` member and workspace dependency + +--- + +#### Epic 5: `DeviceDID` and `IdentityDID` Validation at Construction +**Why it matters:** A DID newtype that accepts arbitrary strings provides false safety. Any malformed DID that reaches the KEL resolver or storage layer can cause confusing errors deep in the stack. + +**Scope:** +- Make the inner fields of `DeviceDID` and `IdentityDID` private: change `DeviceDID(pub String)` to `DeviceDID(String)` in `auths-verifier/src/types.rs`. +- Add `DeviceDID::parse(s: &str) -> Result` that validates the string matches the `did:keri:` pattern using the existing KERI prefix parsing logic. +- Add `DeviceDID::as_str(&self) -> &str` and implement `Display` and `FromStr`. +- Do the same for `IdentityDID`. +- Fix all construction sites in `commands/device/pair/common.rs`, `commands/id/identity.rs`, `commands/id/migrate.rs` that currently use `device_did: String` — replace with `DeviceDID::parse()`. +- Add unit tests: valid DID parses, invalid format returns `DidParseError`, `Display` round-trips through `FromStr`. + +**Files to touch:** +- `crates/auths-verifier/src/types.rs` — make inner fields private, add `parse()`, `as_str()`, `Display`, `FromStr` +- `crates/auths-cli/src/commands/device/pair/common.rs` — fix construction sites +- `crates/auths-cli/src/commands/id/identity.rs` — fix construction sites +- `crates/auths-cli/src/commands/id/migrate.rs` — fix construction sites +- `crates/auths-cli/src/adapters/agent.rs` — replace `key_alias: String` with `KeyAlias` + +--- + +#### Epic 6: `auths-sign` verify-options Allowlist (Security) +**Why it matters:** The `verify-options` flags are passed directly to `ssh-keygen` with no validation. In a GitHub Actions context, these values can originate from PR metadata or environment variables, making this a potential vector for altering verification semantics. + +**Scope:** +- In `crates/auths-cli/src/bin/sign.rs`, before passing `args.verify_options` to `ssh-keygen`, validate each option against an allowlist. +- Permitted options: `verify-time=` (digits only after `=`), `print-pubkeys`, `hashalg=sha256`, `hashalg=sha512`. +- Reject any option not on the allowlist with a specific error: `[AUTHS-E0031] Unsupported verify option '{opt}'. Allowed options: verify-time=`. +- Add unit tests in `bin/sign.rs` for: valid `verify-time=1700000000` passes, `verify-time=abc` fails, an unknown option fails, an injection attempt like `no-touch-required` fails. + +**Files to touch:** +- `crates/auths-cli/src/bin/sign.rs` — add `validate_verify_option(opt: &str) -> Result<()>` and call it before the `ssh-keygen` spawn loop + +--- + +#### Epic 7: Enterprise SAML/OIDC Identity Binding +**Why it matters:** A CISO cannot mandate Auths if device identity cannot be tied to the corporate IdP. This is the single most common enterprise procurement question for developer security tools. + +**Scope:** +- Extend `commands/device/authorization.rs` (already has an `OAuthDeviceFlowProvider` port) to support SAML 2.0 assertion binding in addition to OIDC. +- The binding must produce an attestation event that records: IdP issuer, subject (employee email), authentication time, and authentication context class (e.g., `PasswordProtectedTransport`, `MultiFactor`). +- This attestation must be stored in the KEL as an `ixn` (interaction) event so it is part of the verifiable identity chain. +- Add `auths id bind-idp --provider ` subcommand. +- The `auths verify` output must include `"idp_binding": { "issuer": "...", "subject": "...", "bound_at": "..." }` in `--json` mode. +- Supported IdPs for v1.0.0: Okta, Azure AD (Entra ID), Google Workspace. Generic SAML as a fourth option. +- The `auths-sdk` must expose `IdpBinding` as a public type so Python/Node SDKs can surface it. + +**Files to touch:** +- `crates/auths-cli/src/commands/id/` — new `bind_idp.rs` +- `crates/auths-core/src/ports/platform.rs` — add `SamlAssertionProvider` port +- `crates/auths-infra-http/` — add Okta, Azure AD, Google Workspace OAuth/SAML adapters +- `crates/auths-sdk/src/workflows/` — new `idp_binding.rs` workflow +- `crates/auths-sdk/src/types.rs` — add `IdpBinding` public type +- `crates/auths-verifier/src/types.rs` — include `idp_binding` in `VerifiedIdentity` +- `crates/auths-verifier/src/verify.rs` — surface binding in verification output + +--- + +#### Epic 8: SLSA Provenance and SBOM Attestation +**Why it matters:** Post-EO 14028 (US Executive Order on Cybersecurity), enterprises must produce software supply chain attestations. Auths is perfectly positioned to be the signing layer for SLSA Level 2+ provenance and SPDX/CycloneDX SBOMs. This is the "why not just use GPG" answer for a CISO. + +**Scope:** +- Extend `commands/artifact/` (already exists with `sign`, `verify`, `publish`) to support structured attestation payloads conforming to: + - SLSA Provenance v1.0 (`https://slsa.dev/provenance/v1`) + - SPDX 2.3 SBOM + - CycloneDX 1.5 SBOM + - in-toto attestation framework (link layer) +- `auths artifact sign --slsa-provenance --builder-id --source-uri ` must produce a signed attestation bundle that can be verified by `slsa-verifier` independently. +- `auths artifact verify --policy slsa-level=2` must check that the provenance attestation was signed by a key in the KEL and that the build parameters meet the specified SLSA level. +- Publish attestation bundles to OCI registries (via `oras` or direct OCI push) in addition to git refs, so container image attestations can be stored alongside the image. +- The `auths-verifier` WASM module must be able to verify SLSA attestations without a git repository present (pure in-memory from attestation bundle JSON). + +**Files to touch:** +- `crates/auths-cli/src/commands/artifact/` — extend `sign.rs`, `verify.rs`, `publish.rs` +- `crates/auths-sdk/src/workflows/artifact.rs` — add SLSA/SBOM payload constructors +- `crates/auths-verifier/src/` — add `slsa.rs` for SLSA-specific verification +- New: `crates/auths-oci/` — OCI registry push/pull adapter +- Docs: `docs/attestation/slsa.md`, `docs/attestation/sbom.md` + +--- + +#### Epic 9: GitLab, Bitbucket, and Forgejo Support +**Why it matters:** GitHub Action coverage is in place. But >40% of enterprise git usage is GitLab self-hosted or Bitbucket. Without parity, Auths is a GitHub-only tool in enterprise evaluation. + +**Scope:** +- GitLab CI: provide a `.gitlab-ci.yml` template and a Docker image `ghcr.io/auths-dev/auths-verify:latest` that can be used as a GitLab CI include. Mirrors the GitHub Action interface exactly (same inputs/outputs, same JSON schema). +- Bitbucket Pipelines: provide a Bitbucket Pipe (`auths-dev/auths-verify-pipe`) published to the Atlassian Marketplace. +- Forgejo/Gitea: provide an Actions workflow compatible with Forgejo's GitHub Actions runner. +- The `auths-infra-git` crate should abstract over the specific platform — add a `GitPlatform` enum (`GitHub`, `GitLab`, `Bitbucket`, `Forgejo`, `Generic`) and use it to select the correct commit signing hook format and the correct CI template output from `auths git install-hooks`. +- `auths doctor` must detect which CI platform the current repo is configured for and check the appropriate template is installed. + +**Files to touch:** +- `crates/auths-infra-git/src/` — add platform detection +- `crates/auths-cli/src/commands/git.rs` — extend `install-hooks` for multi-platform +- `crates/auths-cli/src/commands/doctor.rs` — add CI platform checks +- New: `.github/actions/verify/` (already exists), `gitlab/`, `bitbucket-pipe/`, `forgejo/` at repo root +- Docs: `docs/ci/gitlab.md`, `docs/ci/bitbucket.md`, `docs/ci/forgejo.md` + +--- + +#### Epic 10: Managed Witness Infrastructure and SLA (Monetisation Layer) +**Why it matters:** The open-source free tier requires no server. The paid tier requires Auths to operate witness infrastructure. Without this, there is no business. + +**Scope:** +- Operate `witness.auths.dev` as a high-availability witness service. Architecture: 3-node cluster, active-passive with automatic failover, 99.9% uptime SLA for Team tier, 99.99% for Enterprise. +- `auths witness` command gains `--use-managed` flag that registers with `witness.auths.dev` using the OAuth device flow, receives an API token, and stores it in config. +- Managed witness events are timestamped with an RFC 3161 trusted timestamp (e.g., from a public TSA like `timestamp.digicert.com`) so that witness events are independently verifiable even if the Auths service goes offline. +- Add `auths witness status` that shows the current witness configuration and health of the configured witness endpoint. +- Billing integration: Team tier allows up to N witness events/month (start with 10,000), Enterprise is unlimited. Over-limit requests receive a `[AUTHS-E4029] Witness quota exceeded` error with a link to upgrade. +- The witness protocol must be documented publicly so customers can self-host — this is the open-core safety valve that prevents vendor lock-in concerns blocking enterprise adoption. + +**Files to touch:** +- `crates/auths-cli/src/commands/witness.rs` — add `--use-managed`, `status` subcommand +- `crates/auths-core/src/ports/` — add `ManagedWitnessPort` with quota error variant +- `crates/auths-infra-http/src/` — add managed witness HTTP adapter with auth header injection +- New: `services/witness-server/` — the server-side component (separate repo or workspace member) +- Docs: `docs/witness/self-hosting.md`, `docs/witness/managed.md` + +--- + +## Appendix: Pre-Launch Checklist + +| Item | Status | +|---|---| +| `auths init` → `sign` → `verify` end-to-end works | ✅ Implemented | +| GitHub Action CI verification | ✅ Confirmed working | +| WASM verifier (NPM widget) | ✅ Confirmed working | +| Python SDK | ✅ Confirmed working | +| Node.js SDK | ✅ Confirmed working | +| Documentation (quickstart → CI) | ✅ Confirmed complete | +| `auths doctor` | ✅ Functional | +| Device pairing (LAN + online + offline) | ✅ Implemented | +| Key rotation with KEL append | ✅ Implemented | +| Revocation (`emergency`) | ✅ Implemented | +| `verify-options` allowlist in `auths-sign` | ❌ Epic 6 — P0 | +| `DeviceDID`/`IdentityDID` private inner fields | ❌ Epic 5 — P0 | +| CLI integration test suite (init→sign→verify) | ❌ Epic 3 — P0 | +| `expand_tilde` triplicate consolidated | ❌ Epic 4 — P1 | +| `Utc::now()` injection in CLI commands | ❌ Epic 2 — P1 | +| Structured error codes + `auths error ` | ❌ Epic 1 — P1 | +| JSON schema versioned and frozen | ⚠️ Needs freeze before docs publish | +| SCIM server spawn timeout + error message | ⚠️ Low priority | diff --git a/docs/plans/sans_io_spec.md b/docs/plans/sans_io_spec.md deleted file mode 100644 index 31ba8436..00000000 --- a/docs/plans/sans_io_spec.md +++ /dev/null @@ -1,911 +0,0 @@ -# Sans-IO Specification - -## Rule - -Domain crates must not perform direct I/O. All I/O goes through port traits. - -**Domain crates (sans-IO):** `auths-sdk`, `auths-crypto`, `auths-id`, `auths-core` -- No `std::fs` (file reads/writes) -- No `std::process::Command` (shelling out to git, ssh-keygen, etc.) -- No `dirs::home_dir()` or hardcoded paths -- No `std::io` (except in error types) -- No `tokio::fs`, `tokio::process`, `reqwest`, or any network/disk library - -**Adapter crates (I/O allowed):** `auths-infra-git`, `auths-infra-http`, `auths-cli/src/adapters/` -- Implement port traits with real I/O -- This is the only place `std::fs`, `Command::new`, `reqwest`, `git2`, etc. should appear - -**Presentation layer:** `auths-cli/src/commands/` -- Calls SDK workflows, formats and prints results -- Wires adapters into workflows (composition) -- No business logic - -## Why - -1. **Testability.** Workflows can be tested with in-memory stubs. No temp directories, no real git repos, no network mocking. -2. **Embeddability.** The SDK compiles to WASM (no `std::fs` or `std::process` available), C-FFI, and cloud environments. Direct I/O would break this. -3. **Portability.** Swapping git storage for Postgres, or local keychain for cloud KMS, requires only a new adapter — zero workflow changes. -4. **Determinism.** Clock, UUID, and passphrase are all injected via traits. Tests are reproducible. - -## Architecture - -``` -┌─────────────────────────────────────────────────┐ -│ auths-cli │ -│ ┌────────────┐ ┌──────────────────────────┐ │ -│ │ commands/ │ │ adapters/ │ │ -│ │ (present) │ │ (impl port traits) │ │ -│ └─────┬──────┘ └──────────┬───────────────┘ │ -│ │ calls │ implements │ -├────────┼────────────────────┼───────────────────┤ -│ auths-sdk │ │ -│ ┌─────┴──────┐ ┌─────────┴───────────────┐ │ -│ │ workflows/ │ │ ports/ │ │ -│ │ (logic) │──│ (trait definitions) │ │ -│ └────────────┘ └─────────────────────────┘ │ -├─────────────────────────────────────────────────┤ -│ auths-infra-git, auths-infra-http │ -│ (adapter crates — implement port traits) │ -└─────────────────────────────────────────────────┘ -``` - -Dependency direction: adapters depend on ports, never the reverse. - -## How It Works Today - -### Port traits (`auths-sdk/src/ports/`) - -8 port traits define all I/O boundaries: - -| Port | File | What it abstracts | -|------|------|-------------------| -| `AgentSigningPort` | `ports/agent.rs` | IPC with signing agent (Unix socket / noop) | -| `AgentTransport` | `ports/agent.rs` | Connection acceptance for agent daemon | -| `GitLogProvider` | `ports/git.rs` | Reading commit history | -| `GitConfigProvider` | `ports/git_config.rs` | Setting git config values | -| `ArtifactSource` | `ports/artifact.rs` | Artifact digest computation | -| `GitDiagnosticProvider` | `ports/diagnostics.rs` | Git version, config checks | -| `CryptoDiagnosticProvider` | `ports/diagnostics.rs` | SSH keygen availability | -| `DiagnosticFix` | `ports/diagnostics.rs` | Auto-fix for failed checks | - -### DI container (`auths-sdk/src/context.rs`) - -`AuthsContext` holds all injected dependencies as `Arc`: - -```rust -pub struct AuthsContext { - pub registry: Arc, - pub key_storage: Arc, - pub clock: Arc, - pub event_sink: Arc, - pub identity_storage: Arc, - pub attestation_sink: Arc, - pub attestation_source: Arc, - pub passphrase_provider: Arc, - pub uuid_provider: Arc, - pub agent_signing: Arc, -} -``` - -Built via a typestate builder that enforces required fields at compile time. Optional fields default to no-op implementations (`NoopSink`, `NoopAgentProvider`, etc.). - -### Composition root (`auths-cli/src/factories/mod.rs`) - -Single module where adapters are instantiated and wired into workflows: -- `build_config()` — selects passphrase provider (interactive vs. prefilled) -- `build_agent_provider()` — returns platform-specific adapter -- `init_audit_sinks()` — builds telemetry pipeline - -### Workflow pattern - -Every workflow accepts port traits as generics — never concrete types: - -```rust -// Good: generic over provider trait -pub struct DiagnosticsWorkflow { - git: G, - crypto: C, -} - -// Good: uses injected context -pub struct CommitSigningContext { - pub key_storage: Arc, - pub agent_signing: Arc, -} -``` - -## Current Violations - -Two places in domain crates do direct I/O that should eventually be abstracted: - -| Crate | File | Violation | Severity | -|-------|------|-----------|----------| -| `auths-core` | `src/config.rs` | `std::fs::read_to_string`, `std::fs::write` for config loading/saving | Medium — blocks config testing | -| `auths-sdk` | `src/workflows/allowed_signers.rs` | `std::fs::read_to_string`, `std::fs::create_dir_all` for signers file | Medium — blocks mocking in tests | - -These work fine today but should be refactored to use port traits if testing or WASM compatibility becomes a priority. - -## Adding New Workflows: Checklist - -When adding a new workflow that needs I/O: - -1. **Define a port trait** in `auths-sdk/src/ports/` - - Trait methods should be small and focused (Interface Segregation) - - Return domain types, not I/O types (e.g., `Result` not `Result`) - - Include `Send + Sync` bounds for async compatibility - -2. **Write the workflow** in `auths-sdk/src/workflows/` - - Accept the port trait as a generic parameter or field - - Return a structured report (not formatted strings) - - No `use std::fs`, no `use std::process`, no `use dirs` - -3. **Implement the adapter** in `auths-cli/src/adapters/` - - This is where `std::fs`, `Command::new`, `dirs::home_dir` go - - One adapter per port trait (or combine if they share I/O concerns) - -4. **Wire it up** in `auths-cli/src/commands/` or `factories/` - - Instantiate adapter, pass to workflow, print results - -5. **Verify sans-IO** - - `grep -r "std::fs" crates/auths-sdk/src/` should return nothing - - `grep -r "Command::new" crates/auths-sdk/src/` should return nothing - - Same for `auths-crypto`, `auths-id`, `auths-core` - -## Example: Diagnostics - -The diagnostics system is the cleanest example of the full pattern: - -**Port** (`auths-sdk/src/ports/diagnostics.rs`): -```rust -pub trait GitDiagnosticProvider: Send + Sync { - fn check_git_version(&self) -> Result; - fn get_git_config(&self, key: &str) -> Result, DiagnosticError>; -} -``` - -**Workflow** (`auths-sdk/src/workflows/diagnostics.rs`): -```rust -pub struct DiagnosticsWorkflow { - git: G, - crypto: C, -} - -impl DiagnosticsWorkflow { - pub fn run(&self) -> Result { - let mut checks = Vec::new(); - checks.push(self.git.check_git_version()?); - checks.push(self.crypto.check_ssh_keygen_available()?); - self.check_git_signing_config(&mut checks)?; - Ok(DiagnosticReport { checks }) - } -} -``` - -**Adapter** (`auths-cli/src/adapters/system_diagnostic.rs`): -```rust -impl GitDiagnosticProvider for SystemGitDiagnostic { - fn check_git_version(&self) -> Result { - let output = Command::new("git").args(["--version"]).output() - .map_err(|e| DiagnosticError::ExecutionFailed(e.to_string()))?; - // parse output, return CheckResult - } -} -``` - -**CLI** (`auths-cli/src/commands/doctor.rs`): -```rust -let git = SystemGitDiagnostic::new(); -let crypto = SystemCryptoDiagnostic::new(); -let workflow = DiagnosticsWorkflow::new(git, crypto); -let report = workflow.run()?; -// print report to user -``` - -The workflow has zero I/O. Swap `SystemGitDiagnostic` for a mock and you can test every code path without touching the filesystem. - -## Enforcing via Clippy - -The workspace already uses `disallowed-methods` in `clippy.toml` to enforce similar patterns (e.g., `ClockProvider` instead of `Utc::now()`, `UuidProvider` instead of `Uuid::new_v4()`). The same mechanism can enforce sans-IO per crate. - -### How it works - -Clippy walks up from each source file to find the nearest `clippy.toml`. A file at `crates/auths-sdk/clippy.toml` overrides the workspace root one for that crate only. This means per-crate files must **duplicate** the shared workspace rules (clippy does not merge configs). - -### Which crates get per-crate files - -| Crate | Gets sans-IO clippy.toml? | -|-------|--------------------------| -| `auths-sdk` | Yes — already perfectly sans-IO | -| `auths-crypto` | Yes — pure computation, no I/O | -| `auths-id` | Yes — uses ports | -| `auths-core` | Not yet — has 2 violations (`config.rs`, `allowed_signers.rs`) to fix first | -| `auths-infra-git` | No — adapter crate, I/O is correct | -| `auths-infra-http` | No — adapter crate, I/O is correct | -| `auths-cli` | No — adapters + presentation, I/O is correct | - -### What to disallow - -Each per-crate `clippy.toml` should include: - -**Shared workspace rules** (duplicated from root `clippy.toml`): -```toml -allow-unwrap-in-tests = true -allow-expect-in-tests = true -``` - -**`disallowed-methods`** — ban I/O free functions: -```toml -disallowed-methods = [ - # Shared workspace rules - { path = "chrono::offset::Utc::now", reason = "inject ClockProvider", allow-invalid = true }, - { path = "std::time::SystemTime::now", reason = "inject ClockProvider", allow-invalid = true }, - { path = "std::env::var", reason = "use EnvironmentConfig abstraction", allow-invalid = true }, - { path = "uuid::Uuid::new_v4", reason = "use UuidProvider::new_id()" }, - - # Sans-IO: filesystem - { path = "std::fs::read", reason = "sans-IO crate — use a port trait" }, - { path = "std::fs::read_to_string", reason = "sans-IO crate — use a port trait" }, - { path = "std::fs::write", reason = "sans-IO crate — use a port trait" }, - { path = "std::fs::create_dir", reason = "sans-IO crate — use a port trait" }, - { path = "std::fs::create_dir_all", reason = "sans-IO crate — use a port trait" }, - { path = "std::fs::remove_file", reason = "sans-IO crate — use a port trait" }, - { path = "std::fs::remove_dir", reason = "sans-IO crate — use a port trait" }, - { path = "std::fs::remove_dir_all", reason = "sans-IO crate — use a port trait" }, - { path = "std::fs::copy", reason = "sans-IO crate — use a port trait" }, - { path = "std::fs::rename", reason = "sans-IO crate — use a port trait" }, - { path = "std::fs::metadata", reason = "sans-IO crate — use a port trait" }, - { path = "std::fs::read_dir", reason = "sans-IO crate — use a port trait" }, - { path = "std::fs::canonicalize", reason = "sans-IO crate — use a port trait" }, - - # Sans-IO: process - { path = "std::process::Command::new", reason = "sans-IO crate — use a port trait" }, - { path = "std::process::exit", reason = "sans-IO crate — return errors instead" }, - - # Sans-IO: dirs - { path = "dirs::home_dir", reason = "sans-IO crate — inject paths via config", allow-invalid = true }, - { path = "dirs::config_dir", reason = "sans-IO crate — inject paths via config", allow-invalid = true }, - { path = "dirs::data_dir", reason = "sans-IO crate — inject paths via config", allow-invalid = true }, - - # Sans-IO: network - { path = "reqwest::Client::new", reason = "sans-IO crate — use a port trait for HTTP", allow-invalid = true }, - { path = "reqwest::get", reason = "sans-IO crate — use a port trait for HTTP", allow-invalid = true }, -] -``` - -**`disallowed-types`** — ban I/O types entirely: -```toml -disallowed-types = [ - { path = "std::fs::File", reason = "sans-IO crate — use a port trait" }, - { path = "std::process::Command", reason = "sans-IO crate — use a port trait" }, - { path = "std::net::TcpStream", reason = "sans-IO crate — use a port trait" }, - { path = "std::net::TcpListener", reason = "sans-IO crate — use a port trait" }, -] -``` - -### Rollout - -1. Add `clippy.toml` to `crates/auths-sdk/` and `crates/auths-crypto/` first (already clean) -2. Run `cargo clippy -p auths-sdk -p auths-crypto` to confirm no violations -3. Add to `crates/auths-id/` — fix any violations surfaced -4. Fix the 2 violations in `auths-core` (`config.rs`, `allowed_signers.rs`), then add its `clippy.toml` - -## Testing Improvements - -Sans-IO enables faster, more deterministic tests. The infrastructure is partially built — here's what exists and what's missing. - -### What exists today - -**Fakes** (`auths-sdk/src/testing/fakes/`): - -| Fake | Port it stubs | Features | -|------|--------------|----------| -| `FakeGitLogProvider` | `GitLogProvider` | In-memory commits, configurable via `with_commits()`, `poisoned()` for error paths | -| `FakeAgentProvider` | `AgentSigningPort` | Canned signing results, **call recording** via `AgentCall` enum | -| `FakeCryptoDiagnosticProvider` | `CryptoDiagnosticProvider` | Configurable ssh-keygen check results | -| `FakeGitDiagnosticProvider` | `GitDiagnosticProvider` | Configurable git version and config lookups | - -**Fakes** (`auths-id/src/testing/`): - -| Fake | What it stubs | Features | -|------|--------------|----------| -| `FakeRegistryBackend` | `RegistryBackend` | In-memory event storage, key state derivation, attestation tracking | -| `FakeIdentityStorage` | `IdentityStorage` | In-memory identity store | -| `FakeAttestationSink/Source` | `AttestationSink/Source` | In-memory attestation storage | - -**Fixtures** (`auths-id/src/testing/fixtures.rs`): -- `test_inception_event()` — generates valid signed KERI events -- `test_attestation()` — builds minimal attestation fixtures - -**Contract test macros** (`auths-sdk/src/testing/contracts/`): -- `git_log_provider_contract_tests!` — generates 3 test cases that any `GitLogProvider` implementation must pass (walk all, walk with limit=1, walk with limit=0) - -**Context builder** (`auths-sdk/src/context.rs`): -- Optional fields default to no-ops (`NoopSink`, `NoopAgentProvider`) -- Tests use `PrefilledPassphraseProvider` + `MemoryKeychainHandle` - -### What's missing - -**Missing fakes — no stub exists for these ports:** - -| Port | Crate | Impact | -|------|-------|--------| -| `GitConfigProvider` | `auths-sdk` | Can't test git config workflows without real `git config` | -| `ArtifactSource` | `auths-sdk` | Tests inline throwaway `InMemoryArtifact` structs instead of using a shared fake | -| `PairingRelayClient` | `auths-core` | Can't test pairing workflows without a relay | -| `OAuthDeviceFlowProvider` | `auths-core` | Can't test platform claim flows without real OAuth | -| `RegistryClaimClient` | `auths-core` | Can't test registry registration without real HTTP | -| `SshConfigProvider` | `auths-sdk` | New port from cli_cleanup Task 1 — needs a fake from the start | -| `RegistrySyncProvider` | `auths-sdk` | New port from cli_cleanup Task 6 — needs a fake from the start | -| `IdentityResetProvider` | `auths-sdk` | New port from cli_cleanup Task 4 — needs a fake from the start | - -**Missing contract test macros:** -- Only `GitLogProvider` has a contract macro. Every port trait should have one so that fakes and real adapters are tested against the same behavioral spec. - -**Integration tests still use real I/O:** -- `auths-sdk/tests/cases/helpers.rs` creates real git repos via `tempfile::TempDir` + `git2::Repository` -- ~20 references to `tempfile::TempDir` across integration tests -- These are slow (disk I/O, git init) and flaky (temp dir cleanup, git state) - -### Improvement plan - -#### 1. Add missing fakes for existing ports - -For each port without a fake, add one to `auths-sdk/src/testing/fakes/` or `auths-id/src/testing/`. Follow the `FakeAgentProvider` pattern — it's the most complete: -- Constructor with sensible defaults -- Builder methods for configuring responses (`with_commits()`, `sign_fails_with()`, etc.) -- Call recording where useful (lets tests assert "this method was called with these args") - -```rust -// Example: FakeGitConfigProvider -pub struct FakeGitConfigProvider { - configs: HashMap, - set_calls: Mutex>, -} - -impl FakeGitConfigProvider { - pub fn new() -> Self { /* empty config */ } - pub fn with_config(mut self, key: &str, value: &str) -> Self { /* ... */ } - pub fn set_calls(&self) -> Vec<(String, String)> { /* ... */ } -} - -impl GitConfigProvider for FakeGitConfigProvider { - fn set(&self, key: &str, value: &str) -> Result<(), GitConfigError> { - self.set_calls.lock().unwrap().push((key.into(), value.into())); - Ok(()) - } -} -``` - -#### 2. Add contract test macros for all ports - -Extend the `git_log_provider_contract_tests!` pattern to every port trait. Each macro generates tests that both fakes and real adapters must pass: - -```rust -// In auths-sdk/src/testing/contracts/git_config.rs -macro_rules! git_config_provider_contract_tests { - ($provider_factory:expr) => { - #[test] - fn set_and_read_roundtrip() { - let provider = $provider_factory(); - provider.set("gpg.format", "ssh").unwrap(); - // contract: after set, value should be retrievable - } - }; -} -``` - -This ensures fakes behave identically to real adapters. - -#### 3. Add fakes for new ports (cli_cleanup plan) - -Every new port trait from the cli_cleanup plan should ship with a fake on day one: - -| New port | Fake | Key test scenarios | -|----------|------|-------------------| -| `SshConfigProvider` | `FakeSshConfigProvider` | Config with/without UseKeychain, empty config, write succeeds/fails | -| `RegistrySyncProvider` | `FakeRegistrySyncProvider` | Fetch succeeds/fails, push succeeds/fails, no remote | -| `IdentityResetProvider` | `FakeIdentityResetProvider` | Identity exists/doesn't, stale signers entries, ref exists/doesn't | -| `KeyBackupProvider` | `FakeKeyBackupProvider` | Backup not yet done, backup already done, export fails | - -#### 4. Migrate integration tests off real I/O - -The `build_test_context()` helper in `auths-sdk/tests/cases/helpers.rs` creates real git repos. Migrate tests that don't need real git to use `FakeRegistryBackend` + `FakeIdentityStorage` instead: - -- **Keep real I/O for:** true end-to-end tests (signing a real commit, verifying a real signature) -- **Move to fakes for:** workflow logic tests (what happens when registry is missing, when signers file has stale entries, when reset is called with no repo) - -Target: tests that currently take seconds (temp dir + git init) should run in microseconds with in-memory fakes. - -#### 5. Consolidate inline fakes - -Several test files define throwaway fake structs (e.g., `InMemoryArtifact` in artifact tests). Move these to `auths-sdk/src/testing/fakes/` so they're shared and maintained in one place. - ---- - -## Source Map - -| Area | File | What it does | -|------|------|-------------| -| Config violation | `crates/auths-core/src/config.rs` | `std::fs::read_to_string` (L321), `create_dir_all` (L340), `write` (L344) | -| Allowed signers violation | `crates/auths-sdk/src/workflows/allowed_signers.rs` | `std::fs::read_to_string` (L282), `create_dir_all` (L305) | -| SDK ports module | `crates/auths-sdk/src/ports/mod.rs` | Exports: agent, artifact, diagnostics, git, git_config, pairing, platform | -| GitDiagnosticProvider | `crates/auths-sdk/src/ports/diagnostics.rs` | Trait (L64): `check_git_version()`, `get_git_config()` | -| CryptoDiagnosticProvider | `crates/auths-sdk/src/ports/diagnostics.rs` | Trait (L78): `check_ssh_keygen_available()` | -| GitConfigProvider | `crates/auths-sdk/src/ports/git_config.rs` | Trait (L19): `set(key, value)` | -| SDK fakes | `crates/auths-sdk/src/testing/fakes/` | `agent.rs`, `diagnostics.rs`, `git.rs` | -| ID fakes | `crates/auths-id/src/testing/fakes/` | `attestation.rs`, `identity_storage.rs`, `registry.rs` | -| ID fixtures | `crates/auths-id/src/testing/fixtures.rs` | `test_inception_event()` (L31), `test_attestation()` | -| Contract macros | `crates/auths-sdk/src/testing/contracts/` | `git_log_provider_contract_tests!` | -| Integration helpers | `crates/auths-sdk/tests/cases/helpers.rs` | `build_test_context()` (L24), `tempfile::TempDir` (L77, L91) | -| Composition root | `crates/auths-cli/src/factories/mod.rs` | `build_config()` (L37), `build_agent_provider()` (L103), `init_audit_sinks()` (L80) | -| Workspace clippy | `clippy.toml` | 4 `disallowed-methods` rules (ClockProvider, UuidProvider, etc.) | - -## Execution Order - -Tasks have dependencies. Do them in this order: - -1. **Task 1** (clippy enforcement for `auths-sdk` + `auths-crypto`) — standalone, these crates are already clean -2. **Task 2** (missing fakes for existing ports) — standalone, no deps -3. **Task 3** (contract test macros) — after task 2 (macros test the fakes) -4. **Task 4** (fix `allowed_signers.rs` violation) — after task 2 (needs a fake to test the refactored code) -5. **Task 5** (fix `config.rs` violation) — after task 2 (same reason) -6. **Task 6** (clippy enforcement for `auths-id`) — after tasks 4, 5 (may surface violations) -7. **Task 7** (clippy enforcement for `auths-core`) — after task 5 (config.rs must be fixed first) -8. **Task 8** (consolidate inline fakes) — after task 2 (builds on shared fakes) -9. **Task 9** (migrate integration tests off real I/O) — last (needs all fakes in place) - -## Tasks - -### Task 1: Add per-crate clippy.toml to `auths-sdk` and `auths-crypto` - -**Problem:** No compile-time enforcement prevents someone from adding `std::fs` to these crates. - -**Files to create:** -- `crates/auths-sdk/clippy.toml` -- `crates/auths-crypto/clippy.toml` - -Both files have identical content. They must duplicate the workspace rules (clippy does not merge configs) and add sans-IO bans. Use the exact config from the "What to disallow" section above. - -**Verify:** -```bash -cargo clippy -p auths-sdk -p auths-crypto 2>&1 | grep "disallowed" -# Should return nothing — these crates are already clean -``` - -**Done when:** `cargo clippy -p auths-sdk -p auths-crypto` passes with zero warnings. - ---- - -### Task 2: Add missing fakes for existing ports - -**Problem:** 3 existing port traits have no fake implementation, making workflows hard to test without real I/O. - -**Files to create:** - -**2a. `FakeGitConfigProvider`** - -**File:** `crates/auths-sdk/src/testing/fakes/git_config.rs` (new) - -Implements `GitConfigProvider` (defined at `crates/auths-sdk/src/ports/git_config.rs` L19). - -```rust -use std::collections::HashMap; -use std::sync::Mutex; -use crate::ports::git_config::{GitConfigProvider, GitConfigError}; - -pub struct FakeGitConfigProvider { - configs: Mutex>, - set_calls: Mutex>, - fail_on_set: Mutex>, -} - -impl FakeGitConfigProvider { - pub fn new() -> Self { - Self { - configs: Mutex::new(HashMap::new()), - set_calls: Mutex::new(Vec::new()), - fail_on_set: Mutex::new(None), - } - } - - pub fn with_config(self, key: &str, value: &str) -> Self { - self.configs.lock().unwrap().insert(key.into(), value.into()); - self - } - - pub fn set_fails_with(self, msg: &str) -> Self { - *self.fail_on_set.lock().unwrap() = Some(msg.into()); - self - } - - pub fn set_calls(&self) -> Vec<(String, String)> { - self.set_calls.lock().unwrap().clone() - } - - pub fn get(&self, key: &str) -> Option { - self.configs.lock().unwrap().get(key).cloned() - } -} - -impl GitConfigProvider for FakeGitConfigProvider { - fn set(&self, key: &str, value: &str) -> Result<(), GitConfigError> { - if let Some(msg) = self.fail_on_set.lock().unwrap().as_ref() { - return Err(GitConfigError::CommandFailed(msg.clone())); - } - self.set_calls.lock().unwrap().push((key.into(), value.into())); - self.configs.lock().unwrap().insert(key.into(), value.into()); - Ok(()) - } -} -``` - -Follow the `FakeAgentProvider` pattern (call recording, builder methods, configurable failures). - -**2b. `FakeArtifactSource`** - -**File:** `crates/auths-sdk/src/testing/fakes/artifact.rs` (new) - -Implements `ArtifactSource` (defined at `crates/auths-sdk/src/ports/artifact.rs`). Replace the inline `InMemoryArtifact` structs scattered across test files. - -```rust -use crate::ports::artifact::{ArtifactSource, ArtifactDigest, ArtifactMetadata, ArtifactError}; - -pub struct FakeArtifactSource { - digest: ArtifactDigest, - metadata: ArtifactMetadata, -} - -impl FakeArtifactSource { - pub fn new(name: &str, content: &[u8]) -> Self { - // compute sha256 of content, build digest + metadata - todo!() - } -} - -impl ArtifactSource for FakeArtifactSource { - fn digest(&self) -> Result { Ok(self.digest.clone()) } - fn metadata(&self) -> Result { Ok(self.metadata.clone()) } -} -``` - -**2c. Register in mod.rs** - -**File:** `crates/auths-sdk/src/testing/fakes/mod.rs` - -Add exports: -```rust -mod git_config; -mod artifact; -pub use git_config::FakeGitConfigProvider; -pub use artifact::FakeArtifactSource; -``` - -**Verify:** -```bash -cargo test -p auths-sdk 2>&1 | tail -5 -# Should compile and all existing tests still pass -``` - -**Done when:** Both fakes compile, are exported from `auths_sdk::testing::fakes`, and existing tests pass. - ---- - -### Task 3: Add contract test macros for all ports - -**Problem:** Only `GitLogProvider` has a contract macro (`crates/auths-sdk/src/testing/contracts/`). Fakes can silently diverge from real adapter behavior. - -**Files to create:** - -One file per port trait in `crates/auths-sdk/src/testing/contracts/`: -- `git_config.rs` — tests for `GitConfigProvider` -- `diagnostics.rs` — tests for `GitDiagnosticProvider` and `CryptoDiagnosticProvider` -- `artifact.rs` — tests for `ArtifactSource` - -**Pattern to follow** (from `crates/auths-sdk/src/testing/contracts/git_log.rs`): - -```rust -// crates/auths-sdk/src/testing/contracts/git_config.rs -#[macro_export] -macro_rules! git_config_provider_contract_tests { - ($provider_factory:expr) => { - #[test] - fn set_stores_value() { - let provider = $provider_factory(); - let result = provider.set("gpg.format", "ssh"); - assert!(result.is_ok()); - } - - #[test] - fn set_overwrites_existing() { - let provider = $provider_factory(); - provider.set("gpg.format", "ssh").unwrap(); - provider.set("gpg.format", "gpg").unwrap(); - // contract: second set should not error - } - }; -} -``` - -**Register:** Add modules to `crates/auths-sdk/src/testing/contracts/mod.rs`. - -**Use in fake tests:** -```rust -// In crates/auths-sdk/src/testing/fakes/git_config.rs (bottom of file) -#[cfg(test)] -mod tests { - use super::*; - git_config_provider_contract_tests!(|| FakeGitConfigProvider::new()); -} -``` - -**Verify:** -```bash -cargo test -p auths-sdk contract 2>&1 -# Should show contract tests passing for all fakes -``` - -**Done when:** Every port trait with a fake also has a contract macro, and the fake passes all contract tests. - ---- - -### Task 4: Fix `allowed_signers.rs` violation - -**Problem:** `crates/auths-sdk/src/workflows/allowed_signers.rs` calls `std::fs::read_to_string` (L282) and `std::fs::create_dir_all` (L305) directly. - -**Fix:** - -**4a. Define port trait** - -**File:** `crates/auths-sdk/src/ports/allowed_signers.rs` (new) - -```rust -use crate::ports::diagnostics::DiagnosticError; - -pub trait AllowedSignersStore: Send + Sync { - /// Read the allowed_signers file content. Returns empty string if file doesn't exist. - fn read(&self, path: &std::path::Path) -> Result; - - /// Write content to the allowed_signers file, creating parent dirs as needed. - fn write(&self, path: &std::path::Path, content: &str) -> Result<(), std::io::Error>; -} -``` - -Register in `crates/auths-sdk/src/ports/mod.rs`: -```rust -pub mod allowed_signers; -``` - -**4b. Refactor workflow to accept trait** - -**File:** `crates/auths-sdk/src/workflows/allowed_signers.rs` - -Change `AllowedSigners` to accept the store as a generic or `Arc`: - -- Replace `std::fs::read_to_string(&path)` at L282 with `store.read(&path)` -- Replace `std::fs::create_dir_all(parent)` at L305 + the tempfile write with `store.write(&path, &content)` - -**4c. Create adapter** - -**File:** `crates/auths-cli/src/adapters/allowed_signers_store.rs` (new) - -```rust -pub struct FileAllowedSignersStore; - -impl AllowedSignersStore for FileAllowedSignersStore { - fn read(&self, path: &Path) -> Result { - match std::fs::read_to_string(path) { - Ok(content) => Ok(content), - Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(String::new()), - Err(e) => Err(e), - } - } - - fn write(&self, path: &Path, content: &str) -> Result<(), std::io::Error> { - if let Some(parent) = path.parent() { - std::fs::create_dir_all(parent)?; - } - // Use tempfile for atomic write (preserve existing behavior from L314-328) - let mut tmp = tempfile::NamedTempFile::new_in(path.parent().unwrap())?; - std::io::Write::write_all(&mut tmp, content.as_bytes())?; - tmp.persist(path)?; - Ok(()) - } -} -``` - -**4d. Create fake** - -**File:** `crates/auths-sdk/src/testing/fakes/allowed_signers_store.rs` (new) - -```rust -pub struct FakeAllowedSignersStore { - files: Mutex>, -} - -impl FakeAllowedSignersStore { - pub fn new() -> Self { Self { files: Mutex::new(HashMap::new()) } } - pub fn with_file(self, path: &Path, content: &str) -> Self { /* ... */ } - pub fn content(&self, path: &Path) -> Option { /* ... */ } -} - -impl AllowedSignersStore for FakeAllowedSignersStore { - fn read(&self, path: &Path) -> Result { - Ok(self.files.lock().unwrap().get(path).cloned().unwrap_or_default()) - } - fn write(&self, path: &Path, content: &str) -> Result<(), std::io::Error> { - self.files.lock().unwrap().insert(path.to_path_buf(), content.into()); - Ok(()) - } -} -``` - -**4e. Wire up in composition root** - -**File:** `crates/auths-cli/src/factories/mod.rs` — or wherever `AllowedSigners` is instantiated. Pass `FileAllowedSignersStore` as the adapter. - -**Verify:** -```bash -grep -r "std::fs" crates/auths-sdk/src/workflows/allowed_signers.rs -# Should return nothing -cargo test -p auths-sdk 2>&1 | tail -5 -# All tests pass -``` - -**Done when:** `allowed_signers.rs` has zero `std::fs` calls, all tests pass, and `FakeAllowedSignersStore` exists. - ---- - -### Task 5: Fix `config.rs` violation - -**Problem:** `crates/auths-core/src/config.rs` calls `std::fs::read_to_string` (L321), `create_dir_all` (L340), `write` (L344) directly. - -**Fix:** - -**5a. Define port trait** - -**File:** `crates/auths-core/src/ports/config_store.rs` (new, or add to existing ports module) - -```rust -pub trait ConfigStore: Send + Sync { - fn load(&self) -> Result, std::io::Error>; - fn save(&self, content: &str) -> Result<(), std::io::Error>; -} -``` - -**5b. Refactor** `load_config()` and `save_config()` to accept `&dyn ConfigStore` instead of reading/writing files directly. - -**5c. Create adapter** - -**File:** `crates/auths-cli/src/adapters/config_store.rs` (new) - -```rust -pub struct FileConfigStore { - path: PathBuf, -} - -impl ConfigStore for FileConfigStore { - fn load(&self) -> Result, std::io::Error> { - match std::fs::read_to_string(&self.path) { - Ok(s) => Ok(Some(s)), - Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None), - Err(e) => Err(e), - } - } - fn save(&self, content: &str) -> Result<(), std::io::Error> { - if let Some(parent) = self.path.parent() { - std::fs::create_dir_all(parent)?; - } - std::fs::write(&self.path, content) - } -} -``` - -**5d. Create fake** - -**File:** `crates/auths-core/src/testing/` (or wherever auths-core testing lives) - -```rust -pub struct FakeConfigStore { - content: Mutex>, -} -``` - -**Verify:** -```bash -grep -r "std::fs" crates/auths-core/src/config.rs -# Should return nothing -cargo test -p auths-core 2>&1 | tail -5 -``` - -**Done when:** `config.rs` has zero `std::fs` calls, all tests pass, `FakeConfigStore` exists. - ---- - -### Task 6: Add per-crate clippy.toml to `auths-id` - -**Problem:** `auths-id` should be sans-IO but has no compile-time enforcement. - -**File to create:** `crates/auths-id/clippy.toml` - -Same content as Task 1 (duplicate workspace rules + sans-IO bans). - -**Verify:** -```bash -cargo clippy -p auths-id 2>&1 | grep "disallowed" -# Fix any violations found. Likely in hooks.rs (install_cache_hooks uses std::fs). -# Note: hooks.rs may need to stay in auths-id with an #[allow] or move to an adapter. -``` - -**Done when:** `cargo clippy -p auths-id` passes with zero `disallowed` warnings (either by fixing violations or moving I/O code to an infra crate). - ---- - -### Task 7: Add per-crate clippy.toml to `auths-core` - -**Problem:** `auths-core` should be sans-IO but has no compile-time enforcement. Depends on Task 5 (config.rs must be fixed first). - -**File to create:** `crates/auths-core/clippy.toml` - -Same content as Task 1. - -**Verify:** -```bash -cargo clippy -p auths-core 2>&1 | grep "disallowed" -# Should pass after Task 5 is done -``` - -**Done when:** `cargo clippy -p auths-core` passes with zero `disallowed` warnings. - ---- - -### Task 8: Consolidate inline fakes - -**Problem:** Test files define throwaway fake structs (e.g., `InMemoryArtifact`) instead of using shared fakes. - -**Steps:** -1. Search for `struct.*Fake\|struct.*Mock\|struct.*InMemory\|struct.*Stub` in test files under `crates/auths-sdk/tests/` -2. For each inline fake, check if a shared fake exists in `crates/auths-sdk/src/testing/fakes/` -3. If yes, replace the inline fake with the shared one -4. If no, move the inline fake to the shared fakes module - -**Verify:** -```bash -cargo test -p auths-sdk 2>&1 | tail -5 -# All tests pass with shared fakes -``` - -**Done when:** No inline fake structs remain in test files that duplicate shared fakes. - ---- - -### Task 9: Migrate integration tests off real I/O - -**Problem:** `crates/auths-sdk/tests/cases/helpers.rs` creates real git repos via `tempfile::TempDir` (L77, L91) + `git2::Repository::init` (L43). These tests are slow and non-deterministic. - -**Steps:** - -1. Identify which tests actually need real git (signing a real commit, verifying a real signature) vs. which just test workflow logic -2. For workflow logic tests, replace `build_test_context()` / `build_empty_test_context()` with a version that uses `FakeRegistryBackend` + `FakeIdentityStorage` (from `auths-id/src/testing/fakes/`) -3. Keep `build_test_context()` for true end-to-end tests, but add a `build_fake_test_context()` alternative - -```rust -// crates/auths-sdk/tests/cases/helpers.rs -pub fn build_fake_test_context() -> AuthsContext { - let registry = Arc::new(FakeRegistryBackend::new()); - let identity = Arc::new(FakeIdentityStorage::new()); - let attestation_sink = Arc::new(FakeAttestationSink::new()); - let attestation_source = Arc::new(FakeAttestationSource::new()); - // ... wire up with AuthsContext::builder() -} -``` - -4. Migrate tests one by one. Target: tests that don't assert on git state should use fakes. - -**Verify:** -```bash -cargo test -p auths-sdk 2>&1 | tail -5 -# All tests pass -# Bonus: measure time before/after — fake-based tests should be 10-100x faster -``` - -**Done when:** Workflow logic tests use in-memory fakes. `tempfile::TempDir` usage is limited to true end-to-end tests only. diff --git a/docs/plans/typing_cleaning.md b/docs/plans/typing_cleaning.md new file mode 100644 index 00000000..79cb792d --- /dev/null +++ b/docs/plans/typing_cleaning.md @@ -0,0 +1,349 @@ +# Typing Cleaning: Strong Newtypes for Cryptographic String Fields + +## Context + +Many cryptographic and identity fields throughout the codebase are plain `String` where they should be strongly typed newtypes. The fn-62 epic already addresses `IdentityDID` and `DeviceDID` validation. This plan covers **everything else**: signatures, commit SHAs, public keys, resource IDs, KERI prefixes/SAIDs, git refs, and policy IDs. + +## Existing Newtypes (Already Done) + +These live in `crates/auths-verifier/src/` and follow established patterns: + +| Type | Inner | Location | Derives | +|------|-------|----------|---------| +| `ResourceId(String)` | `String` | `core.rs:46` | `Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize` + `Deref, Display, From, From<&str>` | +| `IdentityDID(String)` | `String` | `types.rs:147` | `Debug, Clone, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord, Hash` + `Display, FromStr, Deref, AsRef, Borrow` | +| `DeviceDID(String)` | `String` | `types.rs:303` | Same as IdentityDID + Git-specific utilities | +| `Prefix(String)` | `String` | `keri.rs:66` | `Debug, Clone, Default, PartialEq, Eq, Hash, Serialize, Deserialize` + `Display, AsRef, Borrow` | +| `Said(String)` | `String` | `keri.rs:163` | Same as Prefix | +| `Ed25519PublicKey([u8; 32])` | `[u8; 32]` | `core.rs:181` | `Debug, Clone, Copy, PartialEq, Eq, Hash` + custom hex Serialize/Deserialize | +| `Ed25519Signature([u8; 64])` | `[u8; 64]` | `core.rs:283` | `Debug, Clone, PartialEq, Eq` + custom hex Serialize/Deserialize | + +**Pattern notes:** +- No macros — all hand-written +- String-based types use `#[serde(transparent)]` +- Byte-array types use custom hex Serialize/Deserialize +- All conditionally derive `schemars::JsonSchema` with `#[cfg_attr(feature = "schema", ...)]` +- Validated types provide `new_unchecked()` + `parse()` constructors +- Error types use `thiserror::Error` + +--- + +## New Newtypes to Create + +### 1. `CommitOid(String)` — Git commit hash + +**Where to define:** `crates/auths-verifier/src/core.rs` (alongside `ResourceId`) + +**Validation:** 40-char lowercase hex string (SHA-1) or 64-char (SHA-256 for future Git) + +**Derives & impls:** Same as `ResourceId` — `Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize, Deref, Display, From` + +**Sites to update (4):** + +| File | Field/Param | Current Type | +|------|-------------|-------------| +| `crates/auths-index/src/index.rs:20` | `IndexedAttestation.commit_oid` | `String` | +| `crates/auths-index/src/schema.rs:9` | DB column | `TEXT` (keep as TEXT, convert at boundary) | +| `crates/auths-id/src/keri/cache.rs:42,247` | `CacheEntry.last_commit_oid` | `String` | +| `crates/auths-id/src/identity/events.rs:21` | `IdentityEvent.previous_hash` | `String` | + +### 2. `PublicKeyHex(String)` — Hex-encoded Ed25519 public key + +**Where to define:** `crates/auths-verifier/src/core.rs` + +**Validation:** 64-char hex string (32 bytes encoded) — validate with `hex::decode` and length check + +**Conversion:** `pub fn to_ed25519(&self) -> Result` for parsing into the byte-array type + +**Sites to update (~20):** + +| File | Field/Param | Current Type | +|------|-------------|-------------| +| `crates/auths-verifier/src/core.rs:667` | `IdentityBundle.public_key_hex` | `String` | +| `crates/auths-core/src/trust/roots_file.rs:47` | `TrustedRoot.public_key_hex` | `String` | +| `crates/auths-core/src/trust/pinned.rs:28` | `PinnedIdentity.public_key_hex` | `String` | +| `crates/auths-core/src/testing/builder.rs:69` | builder field | `String` | +| `crates/auths-cli/src/commands/device/authorization.rs:31` | `public_key` | `String` | +| `crates/auths-cli/src/commands/trust.rs:99` | `public_key_hex` | `String` | +| `crates/auths-sdk/src/workflows/org.rs:204,240,256,273` | org admin/member keys | `String` | +| `crates/auths-sdk/src/workflows/mcp.rs:16` | `root_public_key` | `String` | +| `crates/auths-mobile-ffi/src/lib.rs:82,85,351,371,442` | device key fields | `String` | +| `crates/auths-pairing-protocol/src/response.rs:18,19` | x25519/signing pubkeys | `String` | + +### 3. `SignatureHex(String)` — Hex-encoded Ed25519 signature + +**Where to define:** `crates/auths-verifier/src/core.rs` + +**Validation:** 128-char hex string (64 bytes encoded) + +**Conversion:** `pub fn to_ed25519(&self) -> Result` + +**Sites to update (~2):** + +| File | Field/Param | Current Type | +|------|-------------|-------------| +| `crates/auths-pairing-protocol/src/response.rs:21` | `PairingResponse.signature` | `String` | +| `crates/auths-sdk/src/workflows/artifact.rs:169` (if applicable) | signature fields | `String` | + +### 4. `GitRef(String)` — Git ref path + +**Where to define:** `crates/auths-verifier/src/core.rs` or `crates/auths-storage/src/` + +**Validation:** Must start with `refs/` — basic structural check + +**Sites to update (3):** + +| File | Field/Param | Current Type | +|------|-------------|-------------| +| `crates/auths-index/src/index.rs:18` | `IndexedAttestation.git_ref` | `String` | +| `crates/auths-policy/src/context.rs:62` | `VerificationContext.git_ref` | `Option` → `Option` | +| `crates/auths-id/src/keri/kel.rs:88` | `Kel::with_ref()` param | `String` | + +### 5. `PolicyId(String)` — Policy identifier + +**Where to define:** `crates/auths-verifier/src/core.rs` + +**Sites to update (2):** + +| File | Field/Param | Current Type | +|------|-------------|-------------| +| `crates/auths-verifier/src/core.rs:987` | `ThresholdPolicy.policy_id` | `String` | +| `crates/auths-verifier/src/core.rs:1000` | constructor param | `String` | + +--- + +## Existing Newtypes: Inconsistent Adoption + +These types already exist but aren't used everywhere they should be. + +### `ResourceId` — exists at `core.rs:46`, used inconsistently + +| File | Field/Param | Current Type | Should Be | +|------|-------------|-------------|-----------| +| `crates/auths-index/src/index.rs:12` | `IndexedAttestation.rid` | `String` | `ResourceId` | +| `crates/auths-index/src/index.rs:51` | `IndexedOrgMember.rid` | `String` | `ResourceId` | +| `crates/auths-id/src/identity/helpers.rs:28` | `IdentityHelper.rid` | `String` | `ResourceId` | +| `crates/auths-sdk/src/workflows/artifact.rs:28` | `ArtifactSigningRequest.attestation_rid` | `String` | `ResourceId` | +| `crates/auths-sdk/src/signing.rs:193` | `SignedAttestation.rid` | `String` | `ResourceId` | + +### `Prefix` — exists at `keri.rs:66`, used inconsistently + +| File | Field/Param | Current Type | Should Be | +|------|-------------|-------------|-----------| +| `crates/auths-index/src/index.rs:35` | `IndexedIdentity.prefix` | `String` | `Prefix` | +| `crates/auths-index/src/index.rs:48` | `IndexedOrgMember.org_prefix` | `String` | `Prefix` | + +### `Said` — exists at `keri.rs:163`, used inconsistently + +| File | Field/Param | Current Type | Should Be | +|------|-------------|-------------|-----------| +| `crates/auths-index/src/index.rs:38` | `IndexedIdentity.tip_said` | `String` | `Said` | + +### `IdentityDID` / `DeviceDID` — partially addressed by fn-62 + +Additional sites beyond fn-62 scope (core/SDK layer, not CLI boundary): + +| File | Field/Param | Current Type | Should Be | +|------|-------------|-------------|-----------| +| `crates/auths-index/src/index.rs:14,50` | `issuer_did` | `String` | `IdentityDID` | +| `crates/auths-index/src/index.rs:16,49` | `device_did`, `member_did` | `String` | `DeviceDID` | +| `crates/auths-id/src/keri/cache.rs:36,241` | `CacheEntry.did` | `String` | `IdentityDID` | +| `crates/auths-id/src/keri/resolve.rs:44` | `ResolveResult.did` | `String` | `IdentityDID` | +| `crates/auths-id/src/identity/helpers.rs:27` | `IdentityHelper.did` | `String` | `IdentityDID` | +| `crates/auths-sdk/src/types.rs:589` | `DeviceAttestation.device_did` | `String` | `DeviceDID` | +| `crates/auths-sdk/src/workflows/artifact.rs:32` | `ArtifactSigningResult.signer_did` | `String` | `IdentityDID` | +| `crates/auths-sdk/src/workflows/org.rs:196,236,252` | org workflow DIDs | `String` | `IdentityDID`/`DeviceDID` | +| `crates/auths-core/src/witness/server.rs:60,114` | `WitnessConfig.witness_did` | `String` | `DeviceDID` | +| `crates/auths-pairing-protocol/src/response.rs:20` | `PairingResponse.device_did` | `String` | `DeviceDID` | +| `crates/auths-pairing-protocol/src/types.rs:57,81` | pairing DIDs | `String` | `DeviceDID` | + +--- + +## Cascade to FFI Packages + +### Impact Assessment: **Minimal** + +Both `packages/auths-python` (PyO3) and `packages/auths-node` (napi-rs) use a consistent adapter pattern: internal Rust newtypes are converted to `String` at the FFI boundary via `.to_string()` or `hex::encode()`. The FFI-exposed structs remain `String` fields. + +**No wrapper impls needed.** As long as newtypes implement `Display`, the existing `.to_string()` calls continue to work. + +### auths-python (PyO3) + +Binding structs use `#[pyclass]` with `#[pyo3(get)]` on `String` fields. Python consumers receive `str`. + +**Files with conversion points (`.to_string()` calls that may reference changed types):** +- `packages/auths-python/src/identity.rs` — ~6 conversion sites +- `packages/auths-python/src/commit_sign.rs` — signature/DID conversions +- `packages/auths-python/src/attestation_query.rs` — rid, DID conversions +- `packages/auths-python/src/org.rs` — org prefix, DID conversions +- `packages/auths-python/src/artifact_sign.rs` — rid conversions + +**Type stubs (manually maintained, no change needed):** +- `packages/auths-python/python/auths/__init__.pyi` — fields remain `str` + +### auths-node (napi-rs) + +Binding structs use `#[napi(object)]` with `String` fields. JavaScript consumers receive `string`. + +**Files with conversion points:** +- `packages/auths-node/src/identity.rs` — ~8 conversion sites +- `packages/auths-node/src/commit_sign.rs` — signature/DID conversions +- `packages/auths-node/src/artifact.rs` — rid, digest conversions +- `packages/auths-node/src/org.rs` — org prefix, DID conversions +- `packages/auths-node/src/types.rs` — defines all `Napi*` structs + +**Type definitions (auto-generated, no change needed):** +- `packages/auths-node/index.d.ts` — regenerated by napi-rs build + +### What Changes in FFI Code + +For each conversion site, the change is mechanical: + +```rust +// Before (if inner field was public): +did: result.did.0, +// or +did: result.did.to_string(), + +// After (identical — Display impl handles it): +did: result.did.to_string(), +``` + +If a newtype's inner field was previously accessed directly (e.g., `resource_id.0`), change to `.to_string()` or `.as_str().to_owned()`. + +### auths-mobile-ffi (Swift/Kotlin) + +- `crates/auths-mobile-ffi/src/lib.rs` — ~15 DID and public_key_hex fields as `String` +- Same pattern: convert via `.to_string()` at boundary, FFI types remain `String` + +--- + +## Execution Plan + +### Phase 1: Define New Newtypes (additive, non-breaking) + +**Task: Create `CommitOid`, `PublicKeyHex`, `SignatureHex`, `GitRef`, `PolicyId`** + +File: `crates/auths-verifier/src/core.rs` + +Follow the `ResourceId` pattern for each: +1. Define struct with `#[serde(transparent)]` +2. Derive `Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize` +3. Implement `Deref`, `Display`, `From`, `From<&str>`, `AsRef` +4. Add `new()`, `as_str()`, `into_inner()` methods +5. For types with validation (`PublicKeyHex`, `SignatureHex`, `CommitOid`): add `parse()` returning `Result` +6. Re-export from `crates/auths-verifier/src/lib.rs` + +Add tests in `crates/auths-verifier/tests/cases/newtypes.rs`. + +**Estimated touch: 1 file + 1 test file** + +### Phase 2: Adopt Existing Newtypes (`ResourceId`, `Prefix`, `Said`) + +**Task: Replace `String` with existing newtypes where they should already be used** + +This is mostly in `auths-index` and `auths-id`: +1. Add `auths-verifier` dependency to `auths-index/Cargo.toml` (if not present) +2. Replace `rid: String` with `rid: ResourceId` in `IndexedAttestation`, `IndexedOrgMember`, etc. +3. Replace `prefix: String` with `prefix: Prefix` in `IndexedIdentity`, `IndexedOrgMember` +4. Replace `tip_said: String` with `tip_said: Said` in `IndexedIdentity` +5. Update SQL binding code (rusqlite `to_sql`/`from_sql` — may need `impl ToSql for ResourceId` via `as_str()`) +6. Fix compilation in `auths-sdk` and `auths-id` for `rid`, `prefix`, `said` fields + +**Estimated touch: ~8 files across auths-index, auths-id, auths-sdk** + +### Phase 3: Thread `CommitOid` Through Codebase + +1. Replace `commit_oid: String` with `commit_oid: CommitOid` in `IndexedAttestation`, `CacheEntry` +2. Replace `previous_hash: String` with `previous_hash: CommitOid` in `IdentityEvent` +3. Update SQL binding code +4. Update any git2 integration points (convert `git2::Oid` ↔ `CommitOid`) + +**Estimated touch: ~4 files** + +### Phase 4: Thread `PublicKeyHex` Through Codebase + +1. Replace `public_key_hex: String` with `public_key_hex: PublicKeyHex` in: + - `IdentityBundle` (auths-verifier) + - `TrustedRoot`, `PinnedIdentity` (auths-core) + - Org workflow structs (auths-sdk) + - MCP config (auths-sdk) +2. Update builder patterns in `auths-core/src/testing/builder.rs` +3. Update CLI display code + +**Estimated touch: ~12 files** + +### Phase 5: Thread `SignatureHex`, `GitRef`, `PolicyId` + +1. `SignatureHex` in pairing protocol +2. `GitRef` in index, policy, KEL +3. `PolicyId` in threshold policy + +**Estimated touch: ~5 files** + +### Phase 6: Thread DID Types Beyond fn-62 + +After fn-62 completes, extend `IdentityDID`/`DeviceDID` adoption to: +1. `auths-index` — all DID fields +2. `auths-id` — cache, resolve, helpers +3. `auths-sdk` — workflows, types +4. `auths-pairing-protocol` — response and types +5. `auths-core` — witness config + +**Estimated touch: ~15 files** + +### Phase 7: FFI Package Updates + +After core types are threaded: +1. Update `packages/auths-python/src/*.rs` — change any `.0` field access to `.to_string()` or `.as_str()` +2. Update `packages/auths-node/src/*.rs` — same pattern +3. Update `crates/auths-mobile-ffi/src/lib.rs` — same pattern +4. Verify Python type stubs unchanged +5. Verify Node type definitions regenerate correctly + +**Estimated touch: ~10 files, all mechanical** + +--- + +## Summary Table + +| Newtype | Define In | Sites to Update | Priority | Phase | +|---------|-----------|----------------|----------|-------| +| `CommitOid` | auths-verifier | 4 | HIGH | 1, 3 | +| `PublicKeyHex` | auths-verifier | ~20 | HIGH | 1, 4 | +| `SignatureHex` | auths-verifier | ~2 | MEDIUM | 1, 5 | +| `GitRef` | auths-verifier | 3 | MEDIUM | 1, 5 | +| `PolicyId` | auths-verifier | 2 | MEDIUM | 1, 5 | +| `ResourceId` (adopt) | already exists | 5 | HIGH | 2 | +| `Prefix` (adopt) | already exists | 2 | HIGH | 2 | +| `Said` (adopt) | already exists | 1 | HIGH | 2 | +| `IdentityDID` (extend) | fn-62 | ~15 | HIGH | 6 | +| `DeviceDID` (extend) | fn-62 | ~10 | HIGH | 6 | + +**Total: ~89 String fields across ~40 files** + +--- + +## Verification Commands + +```bash +# After each phase: +cargo build --workspace +cargo nextest run --workspace +cargo clippy --all-targets --all-features -- -D warnings +cargo test --all --doc + +# WASM check (auths-verifier only): +cd crates/auths-verifier && cargo check --target wasm32-unknown-unknown --no-default-features --features wasm + +# FFI package checks: +cd packages/auths-node && npm run build +cd packages/auths-python && maturin develop +``` + +## Risks + +1. **Serde backward compatibility** — Adding `#[serde(transparent)]` to new newtypes preserves JSON wire format. No breaking change for existing serialized data. +2. **SQL binding** — `rusqlite` needs `ToSql`/`FromSql` impls for newtypes used in index queries. Implement via `as_str()` delegation. +3. **git2 interop** — `CommitOid` needs conversion from `git2::Oid::to_string()`. Keep as simple `From` impl. +4. **KERI event fields** (`k: Vec`, `n: Vec`, `x: String` in `auths-id/src/keri/event.rs`) — These are base64url-encoded keys per CESR spec. Consider a `CesrKey(String)` type, but this is lower priority since KERI event structures are tightly coupled to the CESR wire format. Defer unless there's a clear validation benefit. From d04d0a45f157b9ebec63b930bd6c4e01f0a62e66 Mon Sep 17 00:00:00 2001 From: bordumb Date: Wed, 11 Mar 2026 17:49:47 +0000 Subject: [PATCH 2/2] refactor(types): make DID fields private, delete ValidatedIdentityDID, add clippy + tests (fn-62.2-62.5) - Make DeviceDID(String) and IdentityDID(String) fields private - Rename new() to new_unchecked() on both types - Convert From/From<&str> to TryFrom with validation - Add validated serde Deserialize impl via parse() - Fix all 100+ construction sites across workspace - Add FFI boundary validation with DeviceDID::parse() - Delete ValidatedIdentityDID from auths-core (consolidated into IdentityDID) - Add per-crate clippy.toml for auths-cli with DID construction lints - Add comprehensive TryFrom, serde round-trip, and error tests --- Cargo.lock | 10 + Cargo.toml | 2 + crates/auths-cli/Cargo.toml | 1 + crates/auths-cli/clippy.toml | 18 ++ .../auths-cli/src/commands/artifact/verify.rs | 10 +- .../src/commands/device/authorization.rs | 3 +- .../src/commands/device/pair/common.rs | 1 + crates/auths-cli/src/commands/emergency.rs | 3 +- crates/auths-cli/src/commands/git.rs | 46 +--- crates/auths-cli/src/commands/id/identity.rs | 5 +- crates/auths-cli/src/commands/init/prompts.rs | 1 + crates/auths-cli/src/commands/key.rs | 4 +- crates/auths-cli/src/commands/org.rs | 21 +- crates/auths-cli/src/commands/scim.rs | 11 +- crates/auths-cli/src/commands/signers.rs | 4 +- .../auths-cli/src/commands/verify_commit.rs | 8 +- crates/auths-cli/src/commands/witness.rs | 17 +- crates/auths-cli/tests/cases/preset.rs | 6 +- crates/auths-cli/tests/cases/verify.rs | 4 +- crates/auths-core/src/lib.rs | 1 - crates/auths-core/src/policy/device.rs | 8 +- crates/auths-core/src/policy/org.rs | 8 +- crates/auths-core/src/signing.rs | 6 +- .../src/storage/android_keystore.rs | 4 +- .../auths-core/src/storage/encrypted_file.rs | 14 +- crates/auths-core/src/storage/pkcs11.rs | 9 +- .../auths-core/src/validated_identity_did.rs | 212 -------------- crates/auths-core/tests/cases/key_export.rs | 6 +- crates/auths-core/tests/cases/pkcs11.rs | 14 +- crates/auths-id/Cargo.toml | 3 +- crates/auths-id/src/agent_identity.rs | 6 +- crates/auths-id/src/attestation/verify.rs | 4 +- .../src/domain/attestation_message.rs | 4 +- crates/auths-id/src/keri/inception.rs | 2 +- crates/auths-id/src/policy/mod.rs | 8 +- crates/auths-id/src/storage/indexed.rs | 4 +- crates/auths-id/src/storage/layout.rs | 58 +--- .../src/storage/registry/org_member.rs | 24 +- .../src/testing/contracts/registry.rs | 18 +- crates/auths-id/src/testing/fakes/registry.rs | 8 +- crates/auths-id/src/testing/fixtures.rs | 4 +- crates/auths-id/tests/cases/lifecycle.rs | 4 +- crates/auths-radicle/src/attestation.rs | 8 +- crates/auths-radicle/src/storage.rs | 6 +- crates/auths-radicle/src/verify.rs | 10 +- crates/auths-radicle/tests/cases/helpers.rs | 8 +- .../auths-radicle/tests/cases/stale_node.rs | 2 +- .../auths-radicle/tests/cases/stale_state.rs | 2 +- crates/auths-sdk/src/device.rs | 4 +- crates/auths-sdk/src/keys.rs | 2 +- crates/auths-sdk/src/pairing/mod.rs | 4 +- crates/auths-sdk/src/setup.rs | 17 +- .../src/workflows/allowed_signers.rs | 10 +- crates/auths-sdk/src/workflows/org.rs | 6 +- .../auths-sdk/tests/cases/allowed_signers.rs | 2 +- crates/auths-sdk/tests/cases/org.rs | 8 +- crates/auths-storage/src/git/adapter.rs | 170 ++++++------ .../src/git/attestation_adapter.rs | 6 +- .../src/git/standalone_attestation.rs | 2 +- .../src/git/standalone_identity.rs | 2 +- crates/auths-utils/Cargo.toml | 15 + crates/auths-utils/src/lib.rs | 2 + crates/auths-utils/src/path.rs | 35 +++ crates/auths-utils/src/url.rs | 19 ++ crates/auths-utils/tests/cases/mod.rs | 2 + crates/auths-utils/tests/cases/path.rs | 30 ++ crates/auths-utils/tests/cases/url.rs | 13 + crates/auths-utils/tests/integration.rs | 1 + crates/auths-verifier/src/core.rs | 36 +-- crates/auths-verifier/src/ffi.rs | 11 +- crates/auths-verifier/src/types.rs | 86 ++++-- crates/auths-verifier/src/verify.rs | 38 +-- .../auths-verifier/tests/cases/did_parsing.rs | 126 +++++++++ .../tests/cases/expiration_skew.rs | 4 +- .../tests/cases/kel_verification.rs | 4 +- .../tests/cases/proptest_core.rs | 6 +- .../tests/cases/revocation_adversarial.rs | 4 +- .../tests/cases/serialization_pinning.rs | 8 +- docs/plans/launch_cleaning.md | 9 +- docs/plans/typing_cleaning.md | 260 +++++++++--------- packages/auths-node/src/attestation_query.rs | 2 +- packages/auths-node/src/identity.rs | 6 +- packages/auths-node/src/org.rs | 2 +- packages/auths-node/src/sign.rs | 4 +- packages/auths-node/src/verify.rs | 2 +- packages/auths-python/Cargo.lock | 9 + .../auths-python/src/attestation_query.rs | 2 +- packages/auths-python/src/identity.rs | 2 +- packages/auths-python/src/identity_sign.rs | 6 +- packages/auths-python/src/org.rs | 2 +- packages/auths-python/src/verify.rs | 2 +- packages/auths-verifier-swift/src/lib.rs | 2 +- 92 files changed, 827 insertions(+), 796 deletions(-) create mode 100644 crates/auths-cli/clippy.toml delete mode 100644 crates/auths-core/src/validated_identity_did.rs create mode 100644 crates/auths-utils/Cargo.toml create mode 100644 crates/auths-utils/src/lib.rs create mode 100644 crates/auths-utils/src/path.rs create mode 100644 crates/auths-utils/src/url.rs create mode 100644 crates/auths-utils/tests/cases/mod.rs create mode 100644 crates/auths-utils/tests/cases/path.rs create mode 100644 crates/auths-utils/tests/cases/url.rs create mode 100644 crates/auths-utils/tests/integration.rs diff --git a/Cargo.lock b/Cargo.lock index 786b324a..e9659f26 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -321,6 +321,7 @@ dependencies = [ "auths-sdk", "auths-storage", "auths-telemetry", + "auths-utils", "auths-verifier", "axum", "base64", @@ -450,6 +451,7 @@ dependencies = [ "auths-infra-http", "auths-policy", "auths-test-utils", + "auths-utils", "auths-verifier", "base58", "base64", @@ -750,6 +752,14 @@ dependencies = [ "tempfile", ] +[[package]] +name = "auths-utils" +version = "0.0.1-rc.8" +dependencies = [ + "dirs", + "thiserror 2.0.18", +] + [[package]] name = "auths-verifier" version = "0.0.1-rc.8" diff --git a/Cargo.toml b/Cargo.toml index 987f4204..11e25a0e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -21,6 +21,7 @@ members = [ "crates/auths-pairing-protocol", "crates/auths-radicle", "crates/auths-scim", + "crates/auths-utils", "crates/xtask", ] @@ -60,6 +61,7 @@ auths-jwt = { path = "crates/auths-jwt", version = "0.0.1-rc.7" } auths-pairing-daemon = { path = "crates/auths-pairing-daemon", version = "0.0.1-rc.8" } auths-pairing-protocol = { path = "crates/auths-pairing-protocol", version = "0.0.1-rc.7" } auths-storage = { path = "crates/auths-storage", version = "0.0.1-rc.4" } +auths-utils = { path = "crates/auths-utils" } insta = { version = "1", features = ["json"] } # Compile crypto-heavy crates with optimizations even in dev/test builds. diff --git a/crates/auths-cli/Cargo.toml b/crates/auths-cli/Cargo.toml index bbcec681..19b1e459 100644 --- a/crates/auths-cli/Cargo.toml +++ b/crates/auths-cli/Cargo.toml @@ -43,6 +43,7 @@ auths-telemetry = { workspace = true, features = ["sink-http"] } auths-verifier = { workspace = true, features = ["native"] } auths-infra-git.workspace = true auths-infra-http.workspace = true +auths-utils.workspace = true tokio = { version = "1", features = ["rt-multi-thread", "macros", "time"] } ring.workspace = true base64.workspace = true diff --git a/crates/auths-cli/clippy.toml b/crates/auths-cli/clippy.toml new file mode 100644 index 00000000..8d799ba9 --- /dev/null +++ b/crates/auths-cli/clippy.toml @@ -0,0 +1,18 @@ +# Duplicated from workspace clippy.toml — keep in sync +# Clippy does NOT merge per-crate configs with workspace config. +# Any changes to the workspace clippy.toml must be replicated here. + +allow-unwrap-in-tests = true +allow-expect-in-tests = true + +disallowed-methods = [ + # === Workspace rules (duplicated from root clippy.toml) === + { path = "chrono::offset::Utc::now", reason = "inject ClockProvider instead of calling Utc::now() directly", allow-invalid = true }, + { 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 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 }, +] diff --git a/crates/auths-cli/src/commands/artifact/verify.rs b/crates/auths-cli/src/commands/artifact/verify.rs index eae8e53f..54dc91d3 100644 --- a/crates/auths-cli/src/commands/artifact/verify.rs +++ b/crates/auths-cli/src/commands/artifact/verify.rs @@ -6,8 +6,8 @@ use std::path::{Path, PathBuf}; use auths_verifier::core::Attestation; use auths_verifier::witness::{WitnessQuorum, WitnessReceipt, WitnessVerifyConfig}; use auths_verifier::{ - Capability, IdentityBundle, VerificationReport, verify_chain, verify_chain_with_capability, - verify_chain_with_witnesses, + Capability, IdentityBundle, IdentityDID, VerificationReport, verify_chain, + verify_chain_with_capability, verify_chain_with_witnesses, }; use super::core::{ArtifactMetadata, ArtifactSource}; @@ -199,7 +199,7 @@ pub async fn handle_verify( chain_report, capability_valid, witness_quorum, - issuer: Some(identity_did), + issuer: Some(identity_did.to_string()), error: None, }, ) @@ -209,7 +209,7 @@ pub async fn handle_verify( fn resolve_identity_key( identity_bundle: &Option, attestation: &Attestation, -) -> Result<(Vec, String)> { +) -> Result<(Vec, IdentityDID)> { if let Some(bundle_path) = identity_bundle { let bundle_content = fs::read_to_string(bundle_path) .with_context(|| format!("Failed to read identity bundle: {:?}", bundle_path))?; @@ -222,7 +222,7 @@ fn resolve_identity_key( let issuer = &attestation.issuer; let pk = resolve_pk_from_did(issuer) .with_context(|| format!("Failed to resolve public key from issuer DID '{}'. Use --identity-bundle for stateless verification.", issuer))?; - Ok((pk, issuer.to_string())) + Ok((pk, issuer.clone())) } } diff --git a/crates/auths-cli/src/commands/device/authorization.rs b/crates/auths-cli/src/commands/device/authorization.rs index 14f3f483..aed7cbbc 100644 --- a/crates/auths-cli/src/commands/device/authorization.rs +++ b/crates/auths-cli/src/commands/device/authorization.rs @@ -456,7 +456,8 @@ fn handle_extend( fn resolve_device(repo_path: &Path, device_did_str: &str) -> Result<()> { let attestation_storage = RegistryAttestationStorage::new(repo_path.to_path_buf()); - let device_did = auths_verifier::types::DeviceDID::new(device_did_str); + #[allow(clippy::disallowed_methods)] // INVARIANT: device_did_str from attestation storage + let device_did = auths_verifier::types::DeviceDID::new_unchecked(device_did_str); let attestations = attestation_storage .load_attestations_for_device(&device_did) .with_context(|| format!("Failed to load attestations for device {device_did_str}"))?; diff --git a/crates/auths-cli/src/commands/device/pair/common.rs b/crates/auths-cli/src/commands/device/pair/common.rs index 29810a75..f32018a8 100644 --- a/crates/auths-cli/src/commands/device/pair/common.rs +++ b/crates/auths-cli/src/commands/device/pair/common.rs @@ -249,6 +249,7 @@ pub(crate) fn handle_pairing_response( ); let keychain = get_platform_keychain_with_config(env_config)?; + #[allow(clippy::disallowed_methods)] // INVARIANT: controller_did from managed identity let controller_identity_did = auths_core::storage::keychain::IdentityDID::new_unchecked(controller_did.clone()); let aliases = keychain diff --git a/crates/auths-cli/src/commands/emergency.rs b/crates/auths-cli/src/commands/emergency.rs index 04ed0f89..9b3da135 100644 --- a/crates/auths-cli/src/commands/emergency.rs +++ b/crates/auths-cli/src/commands/emergency.rs @@ -349,7 +349,8 @@ fn handle_revoke_device(cmd: RevokeDeviceCommand) -> Result<()> { let controller_did = managed_identity.controller_did; let rid = managed_identity.storage_id; - let device_did_obj = DeviceDID::new(device_did.clone()); + #[allow(clippy::disallowed_methods)] // INVARIANT: device_did from managed identity storage + let device_did_obj = DeviceDID::new_unchecked(device_did.clone()); // Look up the device's public key from existing attestations let attestation_storage = RegistryAttestationStorage::new(repo_path.clone()); diff --git a/crates/auths-cli/src/commands/git.rs b/crates/auths-cli/src/commands/git.rs index e6bb60bd..914f600a 100644 --- a/crates/auths-cli/src/commands/git.rs +++ b/crates/auths-cli/src/commands/git.rs @@ -3,6 +3,7 @@ use anyhow::{Context, Result, bail}; use auths_sdk::workflows::allowed_signers::AllowedSigners; use auths_storage::git::RegistryAttestationStorage; +use auths_utils::path::expand_tilde; use clap::{Parser, Subcommand}; #[cfg(unix)] use std::os::unix::fs::PermissionsExt; @@ -203,20 +204,6 @@ auths signers sync --repo "{}" --output "{}" ) } -pub(crate) fn expand_tilde(path: &Path) -> Result { - let path_str = path.to_string_lossy(); - if path_str.starts_with("~/") || path_str == "~" { - let home = dirs::home_dir().context("Failed to determine home directory")?; - if path_str == "~" { - Ok(home) - } else { - Ok(home.join(&path_str[2..])) - } - } else { - Ok(path.to_path_buf()) - } -} - use crate::commands::executable::ExecutableCommand; use crate::config::CliConfig; @@ -231,37 +218,6 @@ mod tests { use super::*; use tempfile::TempDir; - #[test] - fn test_expand_tilde() { - let path = PathBuf::from("~/.auths"); - let result = expand_tilde(&path); - assert!(result.is_ok()); - let expanded = result.unwrap(); - assert!(!expanded.to_string_lossy().contains("~")); - assert!(expanded.ends_with(".auths")); - } - - #[test] - fn test_expand_tilde_bare() { - let path = PathBuf::from("~"); - let result = expand_tilde(&path).unwrap(); - assert_eq!(result, dirs::home_dir().unwrap()); - } - - #[test] - fn test_expand_tilde_absolute_path_unchanged() { - let path = PathBuf::from("/tmp/auths"); - let result = expand_tilde(&path).unwrap(); - assert_eq!(result, PathBuf::from("/tmp/auths")); - } - - #[test] - fn test_expand_tilde_relative_path_unchanged() { - let path = PathBuf::from("relative/path"); - let result = expand_tilde(&path).unwrap(); - assert_eq!(result, PathBuf::from("relative/path")); - } - #[test] fn test_find_git_dir() { let temp = TempDir::new().unwrap(); diff --git a/crates/auths-cli/src/commands/id/identity.rs b/crates/auths-cli/src/commands/id/identity.rs index e73eb82f..45cd9f8c 100644 --- a/crates/auths-cli/src/commands/id/identity.rs +++ b/crates/auths-cli/src/commands/id/identity.rs @@ -13,7 +13,7 @@ use auths_core::{ signing::PassphraseProvider, storage::keychain::{KeyAlias, get_platform_keychain}, }; -use auths_verifier::{IdentityBundle, Prefix}; +use auths_verifier::{IdentityBundle, IdentityDID, Prefix}; use clap::ValueEnum; use crate::commands::registry_overrides::RegistryOverrides; @@ -602,7 +602,8 @@ pub fn handle_id( // Create the bundle let bundle = IdentityBundle { - identity_did: identity.controller_did.to_string(), + #[allow(clippy::disallowed_methods)] // INVARIANT: controller_did from storage + identity_did: IdentityDID::new_unchecked(identity.controller_did.to_string()), public_key_hex, attestation_chain: attestations, bundle_timestamp: Utc::now(), diff --git a/crates/auths-cli/src/commands/init/prompts.rs b/crates/auths-cli/src/commands/init/prompts.rs index 87482862..5534a62c 100644 --- a/crates/auths-cli/src/commands/init/prompts.rs +++ b/crates/auths-cli/src/commands/init/prompts.rs @@ -200,6 +200,7 @@ fn run_github_verification( let controller_did = auths_sdk::pairing::load_controller_did(ctx.identity_storage.as_ref()) .map_err(|e| anyhow::anyhow!("{e}"))?; + #[allow(clippy::disallowed_methods)] // INVARIANT: controller_did from identity storage let identity_did = IdentityDID::new_unchecked(controller_did.clone()); let aliases = ctx .key_storage diff --git a/crates/auths-cli/src/commands/key.rs b/crates/auths-cli/src/commands/key.rs index 3ca9d147..6f3efc83 100644 --- a/crates/auths-cli/src/commands/key.rs +++ b/crates/auths-cli/src/commands/key.rs @@ -142,7 +142,9 @@ pub fn handle_key(cmd: KeyCommand) -> Result<()> { seed_file, controller_did, } => { - let identity_did = IdentityDID::new(controller_did); + #[allow(clippy::disallowed_methods)] + // INVARIANT: controller_did from CLI arg validated by clap + let identity_did = IdentityDID::new_unchecked(controller_did); key_import(&key_alias, &seed_file, &identity_did) } KeySubcommand::CopyBackend { diff --git a/crates/auths-cli/src/commands/org.rs b/crates/auths-cli/src/commands/org.rs index d37d6b55..cd78f65e 100644 --- a/crates/auths-cli/src/commands/org.rs +++ b/crates/auths-cli/src/commands/org.rs @@ -331,7 +331,8 @@ pub fn handle_org(cmd: OrgCommand, ctx: &crate::config::CliConfig) -> Result<()> }; let signer = StorageSigner::new(get_platform_keychain()?); - let org_did = DeviceDID::new(controller_did.to_string()); + #[allow(clippy::disallowed_methods)] // INVARIANT: controller_did from storage + let org_did = DeviceDID::new_unchecked(controller_did.to_string()); let attestation = create_signed_attestation( now, @@ -432,7 +433,9 @@ pub fn handle_org(cmd: OrgCommand, ctx: &crate::config::CliConfig) -> Result<()> let _pkcs8_bytes = decrypt_keypair(&encrypted_key, &passphrase) .context("Failed to decrypt signer key (invalid passphrase?)")?; - let subject_device_did = DeviceDID::new(subject_did.clone()); + #[allow(clippy::disallowed_methods)] + // INVARIANT: subject_did accepts both did:key and did:keri + let subject_device_did = DeviceDID::new_unchecked(subject_did.clone()); // --- Resolve device public key using the custom resolver IF did:key --- let device_resolved = resolver.resolve(&subject_did).with_context(|| { @@ -519,8 +522,8 @@ pub fn handle_org(cmd: OrgCommand, ctx: &crate::config::CliConfig) -> Result<()> let _pkcs8_bytes = decrypt_keypair(&encrypted_key, &pass).context("Failed to decrypt identity key")?; - // Allow both did:key and did:keri as subject input - let subject_device_did = DeviceDID::new(subject_did.clone()); + #[allow(clippy::disallowed_methods)] // INVARIANT: accepts both did:key and did:keri + let subject_device_did = DeviceDID::new_unchecked(subject_did.clone()); let now = Utc::now(); // Look up the subject's public key from existing attestations @@ -568,7 +571,9 @@ pub fn handle_org(cmd: OrgCommand, ctx: &crate::config::CliConfig) -> Result<()> let resolver = DefaultDidResolver::with_repo(&repo_path); let group = AttestationGroup::from_list(attestation_storage.load_all_attestations()?); - let subject_device_did = DeviceDID(subject_did.clone()); + #[allow(clippy::disallowed_methods)] + // INVARIANT: subject_did from CLI arg, used for lookup only + let subject_device_did = DeviceDID::new_unchecked(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 @@ -715,7 +720,8 @@ pub fn handle_org(cmd: OrgCommand, ctx: &crate::config::CliConfig) -> Result<()> ) .context("Failed to add member")?; - let member_did = DeviceDID::new(member.clone()); + #[allow(clippy::disallowed_methods)] // INVARIANT: member DID from org registry + let member_did = DeviceDID::new_unchecked(member.clone()); let attestation_storage = RegistryAttestationStorage::new(repo_path.clone()); attestation_storage .export( @@ -803,7 +809,8 @@ pub fn handle_org(cmd: OrgCommand, ctx: &crate::config::CliConfig) -> Result<()> passphrase_provider: passphrase_provider.as_ref(), }; - let member_did = DeviceDID::new(member.clone()); + #[allow(clippy::disallowed_methods)] // INVARIANT: member DID from org registry + let member_did = DeviceDID::new_unchecked(member.clone()); let revocation = revoke_organization_member( &org_ctx, RevokeMemberCommand { diff --git a/crates/auths-cli/src/commands/scim.rs b/crates/auths-cli/src/commands/scim.rs index d5585fd0..b612c836 100644 --- a/crates/auths-cli/src/commands/scim.rs +++ b/crates/auths-cli/src/commands/scim.rs @@ -6,6 +6,8 @@ use std::path::PathBuf; use anyhow::{Context, Result}; use clap::{Parser, Subcommand}; +use auths_utils::url::mask_url; + use crate::commands::executable::ExecutableCommand; use crate::config::CliConfig; @@ -397,15 +399,6 @@ fn generate_token_b64() -> String { base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(bytes) } -fn mask_url(url: &str) -> String { - if let Some(at_pos) = url.find('@') - && let Some(scheme_end) = url.find("://") - { - return format!("{}://***@{}", &url[..scheme_end], &url[at_pos + 1..]); - } - url.to_string() -} - impl ExecutableCommand for ScimCommand { fn execute(&self, _ctx: &CliConfig) -> Result<()> { handle_scim(self.clone()) diff --git a/crates/auths-cli/src/commands/signers.rs b/crates/auths-cli/src/commands/signers.rs index a8f8da46..2110a44c 100644 --- a/crates/auths-cli/src/commands/signers.rs +++ b/crates/auths-cli/src/commands/signers.rs @@ -10,8 +10,8 @@ use clap::{Parser, Subcommand}; use ssh_key::PublicKey as SshPublicKey; use std::path::PathBuf; -use super::git::expand_tilde; use crate::adapters::allowed_signers_store::FileAllowedSignersStore; +use auths_utils::path::expand_tilde; #[derive(Parser, Debug, Clone)] #[command(about = "Manage allowed signers for Git commit verification.")] @@ -89,7 +89,7 @@ fn resolve_signers_path() -> Result { let path_str = String::from_utf8_lossy(&out.stdout).trim().to_string(); if !path_str.is_empty() { let path = PathBuf::from(&path_str); - return expand_tilde(&path); + return Ok(expand_tilde(&path)?); } } diff --git a/crates/auths-cli/src/commands/verify_commit.rs b/crates/auths-cli/src/commands/verify_commit.rs index 3c714554..d9d5f54a 100644 --- a/crates/auths-cli/src/commands/verify_commit.rs +++ b/crates/auths-cli/src/commands/verify_commit.rs @@ -913,7 +913,7 @@ mod tests { #[tokio::test] async fn verify_bundle_chain_empty_chain() { let bundle = IdentityBundle { - identity_did: "did:keri:test".into(), + identity_did: auths_verifier::IdentityDID::new_unchecked("did:keri:test"), public_key_hex: "aa".repeat(32), attestation_chain: vec![], bundle_timestamp: Utc::now(), @@ -929,13 +929,13 @@ mod tests { #[tokio::test] async fn verify_bundle_chain_invalid_hex() { let bundle = IdentityBundle { - identity_did: "did:keri:test".into(), + identity_did: auths_verifier::IdentityDID::new_unchecked("did:keri:test"), public_key_hex: "not_hex".into(), attestation_chain: vec![auths_verifier::core::Attestation { version: 1, rid: "test".into(), - issuer: "did:keri:test".into(), - subject: auths_verifier::DeviceDID::new("did:key:test"), + issuer: auths_verifier::IdentityDID::new_unchecked("did:keri:test"), + subject: auths_verifier::DeviceDID::new_unchecked("did:key:zTest"), device_public_key: auths_verifier::Ed25519PublicKey::from_bytes([0u8; 32]), identity_signature: auths_verifier::core::Ed25519Signature::empty(), device_signature: auths_verifier::core::Ed25519Signature::empty(), diff --git a/crates/auths-cli/src/commands/witness.rs b/crates/auths-cli/src/commands/witness.rs index 63e2d4aa..329a9593 100644 --- a/crates/auths-cli/src/commands/witness.rs +++ b/crates/auths-cli/src/commands/witness.rs @@ -4,6 +4,7 @@ use std::net::SocketAddr; use std::path::{Path, PathBuf}; use anyhow::{Result, anyhow}; +use auths_utils::path::expand_tilde; use clap::{Parser, Subcommand}; use auths_core::witness::{WitnessServerConfig, WitnessServerState, run_server}; @@ -178,26 +179,12 @@ pub fn handle_witness(cmd: WitnessCommand, repo_opt: Option) -> Result< /// Expands leading `~/` so paths from clap defaults work correctly. fn resolve_repo_path(repo_opt: Option) -> Result { if let Some(path) = repo_opt { - return expand_tilde(&path); + return Ok(expand_tilde(&path)?); } let home = dirs::home_dir().ok_or_else(|| anyhow!("Could not determine home directory"))?; Ok(home.join(".auths")) } -fn expand_tilde(path: &std::path::Path) -> Result { - let s = path.to_string_lossy(); - if s.starts_with("~/") || s == "~" { - let home = dirs::home_dir().ok_or_else(|| anyhow!("Could not determine home directory"))?; - if s == "~" { - Ok(home) - } else { - Ok(home.join(&s[2..])) - } - } else { - Ok(path.to_path_buf()) - } -} - /// Load witness config from identity metadata. fn load_witness_config(repo_path: &Path) -> Result { let storage = RegistryIdentityStorage::new(repo_path.to_path_buf()); diff --git a/crates/auths-cli/tests/cases/preset.rs b/crates/auths-cli/tests/cases/preset.rs index 4ca1a241..bbaf1e39 100644 --- a/crates/auths-cli/tests/cases/preset.rs +++ b/crates/auths-cli/tests/cases/preset.rs @@ -13,7 +13,7 @@ fn test_default_layout() { assert_eq!(identity_ref(&config), "refs/auths/identity"); - let device_did = DeviceDID::new("did:key:z6MkTest123"); + let device_did = DeviceDID::new_unchecked("did:key:z6MkTest123"); let attestation_ref = attestation_ref_for_device(&config, &device_did); assert!( attestation_ref.starts_with("refs/auths/keys/"), @@ -31,7 +31,7 @@ fn test_radicle_preset() { assert_eq!(identity_ref(&config), "refs/rad/id"); - let device_did = DeviceDID::new("did:key:z6MkTest123"); + let device_did = DeviceDID::new_unchecked("did:key:z6MkTest123"); let attestation_ref = attestation_ref_for_device(&config, &device_did); assert!( attestation_ref.starts_with("refs/keys/"), @@ -49,7 +49,7 @@ fn test_gitoxide_preset_ref_paths() { assert_eq!(identity_ref(&config), "refs/auths/id"); - let device_did = DeviceDID::new("did:key:z6MkTest789"); + let device_did = DeviceDID::new_unchecked("did:key:z6MkTest789"); let attestation_ref = attestation_ref_for_device(&config, &device_did); assert!( attestation_ref.starts_with("refs/auths/devices/"), diff --git a/crates/auths-cli/tests/cases/verify.rs b/crates/auths-cli/tests/cases/verify.rs index ca2ceb6c..6c6e0d9d 100644 --- a/crates/auths-cli/tests/cases/verify.rs +++ b/crates/auths-cli/tests/cases/verify.rs @@ -22,11 +22,11 @@ fn create_signed_attestation( let mut att = Attestation { version: 1, rid: ResourceId::new("test-rid"), - issuer: IdentityDID::new(format!( + issuer: IdentityDID::new_unchecked(format!( "did:key:{}", hex::encode(issuer_kp.public_key().as_ref()) )), - subject: DeviceDID::new(format!( + subject: DeviceDID::new_unchecked(format!( "did:key:{}", hex::encode(device_kp.public_key().as_ref()) )), diff --git a/crates/auths-core/src/lib.rs b/crates/auths-core/src/lib.rs index 7c8f785d..73464866 100644 --- a/crates/auths-core/src/lib.rs +++ b/crates/auths-core/src/lib.rs @@ -59,7 +59,6 @@ pub mod storage; pub mod testing; pub mod trust; pub mod utils; -pub mod validated_identity_did; pub mod witness; pub use agent::{AgentCore, AgentHandle, AgentSession}; diff --git a/crates/auths-core/src/policy/device.rs b/crates/auths-core/src/policy/device.rs index a124eb9f..3a9372ba 100644 --- a/crates/auths-core/src/policy/device.rs +++ b/crates/auths-core/src/policy/device.rs @@ -3,6 +3,8 @@ //! This module implements the device authorization rules that determine //! whether a device attestation grants permission for a specific action. +#[cfg(test)] +use auths_verifier::IdentityDID; use auths_verifier::core::{Attestation, Capability}; use chrono::{DateTime, Utc}; @@ -83,7 +85,7 @@ impl Action { /// version: 1, /// rid: "test".into(), /// issuer: "did:keri:ETest".into(), -/// subject: DeviceDID::new("did:key:z6Mk..."), +/// subject: DeviceDID::new_unchecked("did:key:z6Mk..."), /// device_public_key: Ed25519PublicKey::from_bytes([0u8; 32]), /// identity_signature: Ed25519Signature::empty(), /// device_signature: Ed25519Signature::empty(), @@ -184,8 +186,8 @@ mod tests { Attestation { version: 1, rid: "test-rid".into(), - issuer: issuer.into(), - subject: DeviceDID::new("did:key:z6MkTest"), + issuer: IdentityDID::new_unchecked(issuer), + subject: DeviceDID::new_unchecked("did:key:z6MkTest"), device_public_key: Ed25519PublicKey::from_bytes([0u8; 32]), identity_signature: Ed25519Signature::empty(), device_signature: Ed25519Signature::empty(), diff --git a/crates/auths-core/src/policy/org.rs b/crates/auths-core/src/policy/org.rs index fd0e5f29..3a75d1f7 100644 --- a/crates/auths-core/src/policy/org.rs +++ b/crates/auths-core/src/policy/org.rs @@ -18,6 +18,8 @@ //! with filtering (role, capabilities), use the registry's `list_org_members()` //! with `MemberFilter`, then apply this policy to each result. +#[cfg(test)] +use auths_verifier::IdentityDID; use auths_verifier::core::{Attestation, Capability}; use auths_verifier::keri::Prefix; use chrono::{DateTime, Utc}; @@ -64,7 +66,7 @@ use super::device::Action; /// version: 1, /// rid: "member".into(), /// issuer: "did:keri:EOrg123".into(), -/// subject: DeviceDID::new("did:key:z6MkAlice"), +/// subject: DeviceDID::new_unchecked("did:key:z6MkAlice"), /// device_public_key: Ed25519PublicKey::from_bytes([0u8; 32]), /// identity_signature: Ed25519Signature::empty(), /// device_signature: Ed25519Signature::empty(), @@ -186,8 +188,8 @@ mod tests { Attestation { version: 1, rid: ResourceId::new("membership"), - issuer: issuer.into(), - subject: DeviceDID::new("did:key:z6MkMember"), + issuer: IdentityDID::new_unchecked(issuer), + subject: DeviceDID::new_unchecked("did:key:z6MkMember"), device_public_key: Ed25519PublicKey::from_bytes([0u8; 32]), identity_signature: Ed25519Signature::empty(), device_signature: Ed25519Signature::empty(), diff --git a/crates/auths-core/src/signing.rs b/crates/auths-core/src/signing.rs index 9fe83f35..4851c5e4 100644 --- a/crates/auths-core/src/signing.rs +++ b/crates/auths-core/src/signing.rs @@ -782,7 +782,7 @@ mod tests { fn test_sign_for_identity_success() { let (pkcs8_bytes, pubkey_bytes) = generate_test_keypair(); let passphrase = "Test-P@ss12345"; - let identity_did = IdentityDID::new("did:keri:ABC123"); + let identity_did = IdentityDID::new_unchecked("did:keri:ABC123"); let alias = KeyAlias::new_unchecked("test-key-alias"); // Encrypt the key @@ -815,7 +815,7 @@ mod tests { let signer = StorageSigner::new(storage); let passphrase_provider = MockPassphraseProvider::new("any-passphrase"); - let identity_did = IdentityDID::new("did:keri:NONEXISTENT"); + let identity_did = IdentityDID::new_unchecked("did:keri:NONEXISTENT"); let message = b"test message"; let result = signer.sign_for_identity(&identity_did, &passphrase_provider, message); @@ -827,7 +827,7 @@ mod tests { // Test that sign_for_identity works when multiple aliases exist for an identity let (pkcs8_bytes, pubkey_bytes) = generate_test_keypair(); let passphrase = "Test-P@ss12345"; - let identity_did = IdentityDID::new("did:keri:MULTI123"); + let identity_did = IdentityDID::new_unchecked("did:keri:MULTI123"); let encrypted = encrypt_keypair(&pkcs8_bytes, passphrase).expect("Failed to encrypt"); diff --git a/crates/auths-core/src/storage/android_keystore.rs b/crates/auths-core/src/storage/android_keystore.rs index 964c5538..6d89d39d 100644 --- a/crates/auths-core/src/storage/android_keystore.rs +++ b/crates/auths-core/src/storage/android_keystore.rs @@ -104,7 +104,7 @@ mod tests { #[test] fn test_store_key_returns_unavailable() { let storage = AndroidKeystoreStorage::new("test").unwrap(); - let did = IdentityDID::new("did:keri:test"); + let did = IdentityDID::new_unchecked("did:keri:test"); let alias = KeyAlias::new("alias"); let result = storage.store_key(&alias, &did, KeyRole::Primary, b"data"); assert!(matches!(result, Err(AgentError::BackendUnavailable { .. }))); @@ -136,7 +136,7 @@ mod tests { #[test] fn test_list_aliases_for_identity_returns_unavailable() { let storage = AndroidKeystoreStorage::new("test").unwrap(); - let did = IdentityDID::new("did:keri:test"); + let did = IdentityDID::new_unchecked("did:keri:test"); let result = storage.list_aliases_for_identity(&did); assert!(matches!(result, Err(AgentError::BackendUnavailable { .. }))); } diff --git a/crates/auths-core/src/storage/encrypted_file.rs b/crates/auths-core/src/storage/encrypted_file.rs index 74876cec..0eda1972 100644 --- a/crates/auths-core/src/storage/encrypted_file.rs +++ b/crates/auths-core/src/storage/encrypted_file.rs @@ -436,7 +436,7 @@ mod tests { fn test_store_and_load_key() { let (storage, _temp) = create_test_storage(); let alias = KeyAlias::new("test-alias").unwrap(); - let identity_did = IdentityDID::new("did:keri:test123"); + let identity_did = IdentityDID::new_unchecked("did:keri:test123"); let encrypted_data = b"encrypted_key_bytes"; storage @@ -452,7 +452,7 @@ mod tests { #[test] fn test_list_aliases() { let (storage, _temp) = create_test_storage(); - let did = IdentityDID::new("did:keri:test"); + let did = IdentityDID::new_unchecked("did:keri:test"); storage .store_key( @@ -485,8 +485,8 @@ mod tests { #[test] fn test_list_aliases_for_identity() { let (storage, _temp) = create_test_storage(); - let did1 = IdentityDID::new("did:keri:one"); - let did2 = IdentityDID::new("did:keri:two"); + let did1 = IdentityDID::new_unchecked("did:keri:one"); + let did2 = IdentityDID::new_unchecked("did:keri:two"); storage .store_key( @@ -524,7 +524,7 @@ mod tests { #[test] fn test_delete_key() { let (storage, _temp) = create_test_storage(); - let did = IdentityDID::new("did:keri:test"); + let did = IdentityDID::new_unchecked("did:keri:test"); let alias = KeyAlias::new("alias").unwrap(); storage @@ -542,7 +542,7 @@ mod tests { #[test] fn test_get_identity_for_alias() { let (storage, _temp) = create_test_storage(); - let did = IdentityDID::new("did:keri:test123"); + let did = IdentityDID::new_unchecked("did:keri:test123"); let alias = KeyAlias::new("alias").unwrap(); storage @@ -562,7 +562,7 @@ mod tests { #[test] fn test_file_format_version() { let (storage, _temp) = create_test_storage(); - let did = IdentityDID::new("did:keri:test"); + let did = IdentityDID::new_unchecked("did:keri:test"); storage .store_key( diff --git a/crates/auths-core/src/storage/pkcs11.rs b/crates/auths-core/src/storage/pkcs11.rs index 46375e8e..d099de52 100644 --- a/crates/auths-core/src/storage/pkcs11.rs +++ b/crates/auths-core/src/storage/pkcs11.rs @@ -227,7 +227,7 @@ impl KeyStorage for Pkcs11KeyRef { }) .ok_or(AgentError::KeyNotFound)?; - let identity_did = IdentityDID::new( + let identity_did = IdentityDID::new_unchecked( String::from_utf8(id_bytes) .map_err(|e| AgentError::KeyDeserializationError(e.to_string()))?, ); @@ -317,9 +317,10 @@ impl KeyStorage for Pkcs11KeyRef { }) .ok_or(AgentError::KeyNotFound)?; - Ok(IdentityDID::new(String::from_utf8(id_bytes).map_err( - |e| AgentError::KeyDeserializationError(e.to_string()), - )?)) + Ok(IdentityDID::new_unchecked( + String::from_utf8(id_bytes) + .map_err(|e| AgentError::KeyDeserializationError(e.to_string()))?, + )) } fn backend_name(&self) -> &'static str { diff --git a/crates/auths-core/src/validated_identity_did.rs b/crates/auths-core/src/validated_identity_did.rs deleted file mode 100644 index 77461c10..00000000 --- a/crates/auths-core/src/validated_identity_did.rs +++ /dev/null @@ -1,212 +0,0 @@ -//! Newtype for `did:keri:` identifiers. -//! -//! Parsing and validation happen at construction time so downstream code -//! can rely on the invariant without re-parsing. - -use std::fmt; - -use serde::{Deserialize, Serialize}; - -const PREFIX: &str = "did:keri:"; - -/// A validated `did:keri:` identifier. -/// -/// The inner string always starts with `"did:keri:"` followed by a non-empty -/// KERI prefix. Construction is fallible — use [`ValidatedIdentityDID::parse`] or -/// [`TryFrom`]. -/// -/// This is the validated form of `IdentityDID` (from `auths-verifier`). -/// `IdentityDID` is an unvalidated newtype for API boundaries; -/// `ValidatedIdentityDID` enforces format invariants at construction. -/// -/// Usage: -/// ```ignore -/// let did = ValidatedIdentityDID::parse("did:keri:EXq5abc")?; -/// assert_eq!(did.prefix(), "EXq5abc"); -/// assert_eq!(did.as_str(), "did:keri:EXq5abc"); -/// ``` -#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] -#[serde(try_from = "String", into = "String")] -pub struct ValidatedIdentityDID(String); - -impl ValidatedIdentityDID { - /// Parse a `did:keri:` string, returning an error if the format is invalid. - /// - /// Args: - /// * `s`: A string that must start with `"did:keri:"` followed by a non-empty prefix. - /// - /// Usage: - /// ```ignore - /// let did = ValidatedIdentityDID::parse("did:keri:EXq5")?; - /// ``` - pub fn parse(s: &str) -> Result { - let keri_prefix = s - .strip_prefix(PREFIX) - .ok_or(IdentityDIDError::MissingPrefix)?; - if keri_prefix.is_empty() { - return Err(IdentityDIDError::EmptyPrefix); - } - Ok(Self(s.to_string())) - } - - /// Build a `ValidatedIdentityDID` from a raw KERI prefix (without the `did:keri:` scheme). - /// - /// Args: - /// * `prefix`: The bare KERI prefix string (e.g. `"EXq5abc"`). - /// - /// Usage: - /// ```ignore - /// let did = ValidatedIdentityDID::from_prefix("EXq5abc"); - /// assert_eq!(did.as_str(), "did:keri:EXq5abc"); - /// ``` - pub fn from_prefix(prefix: &str) -> Result { - if prefix.is_empty() { - return Err(IdentityDIDError::EmptyPrefix); - } - Ok(Self(format!("{}{}", PREFIX, prefix))) - } - - /// Returns the KERI prefix portion (everything after `did:keri:`). - pub fn prefix(&self) -> &str { - // Safe: invariant established at construction - &self.0[PREFIX.len()..] - } - - /// Returns the full DID string. - pub fn as_str(&self) -> &str { - &self.0 - } -} - -impl fmt::Display for ValidatedIdentityDID { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - f.write_str(&self.0) - } -} - -impl AsRef for ValidatedIdentityDID { - fn as_ref(&self) -> &str { - &self.0 - } -} - -impl From for String { - fn from(did: ValidatedIdentityDID) -> Self { - did.0 - } -} - -impl TryFrom for ValidatedIdentityDID { - type Error = IdentityDIDError; - - fn try_from(s: String) -> Result { - let keri_prefix = s - .strip_prefix(PREFIX) - .ok_or(IdentityDIDError::MissingPrefix)?; - if keri_prefix.is_empty() { - return Err(IdentityDIDError::EmptyPrefix); - } - Ok(Self(s)) - } -} - -impl TryFrom<&str> for ValidatedIdentityDID { - type Error = IdentityDIDError; - - fn try_from(s: &str) -> Result { - Self::parse(s) - } -} - -/// Error from parsing an invalid `did:keri:` string. -#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] -#[non_exhaustive] -pub enum IdentityDIDError { - /// The `did:keri:` prefix is absent. - #[error("not a did:keri: identifier")] - MissingPrefix, - - /// The prefix portion is empty. - #[error("did:keri: prefix is empty")] - EmptyPrefix, -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn parse_valid() { - let did = ValidatedIdentityDID::parse("did:keri:EXq5abc123").unwrap(); - assert_eq!(did.prefix(), "EXq5abc123"); - assert_eq!(did.as_str(), "did:keri:EXq5abc123"); - assert_eq!(did.to_string(), "did:keri:EXq5abc123"); - } - - #[test] - fn from_prefix_valid() { - let did = ValidatedIdentityDID::from_prefix("EXq5abc123").unwrap(); - assert_eq!(did.as_str(), "did:keri:EXq5abc123"); - assert_eq!(did.prefix(), "EXq5abc123"); - } - - #[test] - fn rejects_non_keri() { - assert_eq!( - ValidatedIdentityDID::parse("did:key:z6Mk123"), - Err(IdentityDIDError::MissingPrefix) - ); - } - - #[test] - fn rejects_empty_prefix() { - assert_eq!( - ValidatedIdentityDID::parse("did:keri:"), - Err(IdentityDIDError::EmptyPrefix) - ); - } - - #[test] - fn rejects_missing_scheme() { - assert_eq!( - ValidatedIdentityDID::parse("EXq5abc"), - Err(IdentityDIDError::MissingPrefix) - ); - } - - #[test] - fn from_prefix_rejects_empty() { - assert_eq!( - ValidatedIdentityDID::from_prefix(""), - Err(IdentityDIDError::EmptyPrefix) - ); - } - - #[test] - fn try_from_string() { - let did: ValidatedIdentityDID = "did:keri:EXq5".to_string().try_into().unwrap(); - assert_eq!(did.prefix(), "EXq5"); - } - - #[test] - fn into_string() { - let did = ValidatedIdentityDID::parse("did:keri:EXq5").unwrap(); - let s: String = did.into(); - assert_eq!(s, "did:keri:EXq5"); - } - - #[test] - fn serde_roundtrip() { - let did = ValidatedIdentityDID::parse("did:keri:EXq5abc").unwrap(); - let json = serde_json::to_string(&did).unwrap(); - assert_eq!(json, r#""did:keri:EXq5abc""#); - let parsed: ValidatedIdentityDID = serde_json::from_str(&json).unwrap(); - assert_eq!(parsed, did); - } - - #[test] - fn serde_rejects_invalid() { - let result: Result = serde_json::from_str(r#""did:key:z6Mk""#); - assert!(result.is_err()); - } -} diff --git a/crates/auths-core/tests/cases/key_export.rs b/crates/auths-core/tests/cases/key_export.rs index c2b41812..433d089d 100644 --- a/crates/auths-core/tests/cases/key_export.rs +++ b/crates/auths-core/tests/cases/key_export.rs @@ -74,7 +74,7 @@ fn test_export_ring_compatible_key() { let keychain = fresh_keychain(); let alias = "test-ring-key"; let passphrase = "Test-P@ss12345"; - let identity_did = IdentityDID::new("did:keri:test123"); + let identity_did = IdentityDID::new_unchecked("did:keri:test123"); // Create and store a ring-compatible key let (pkcs8_bytes, _expected_pubkey) = create_ring_compatible_pkcs8(); @@ -112,7 +112,7 @@ fn test_export_non_ring_compatible_key() { let keychain = fresh_keychain(); let alias = "test-nonring-key"; let passphrase = "Test-P@ss12345"; - let identity_did = IdentityDID::new("did:keri:test456"); + let identity_did = IdentityDID::new_unchecked("did:keri:test456"); // Create and store a key that ring can't parse let (pkcs8_bytes, _) = create_non_ring_pkcs8(); @@ -151,7 +151,7 @@ fn test_export_with_wrong_passphrase() { let alias = "test-wrong-pass"; let passphrase = "Corr3ct-P@sswd!"; let wrong_passphrase = "Wr0ng-P@ssword!"; - let identity_did = IdentityDID::new("did:keri:test789"); + let identity_did = IdentityDID::new_unchecked("did:keri:test789"); let (pkcs8_bytes, _) = create_ring_compatible_pkcs8(); let encrypted = encrypt_keypair(&pkcs8_bytes, passphrase).expect("Failed to encrypt"); diff --git a/crates/auths-core/tests/cases/pkcs11.rs b/crates/auths-core/tests/cases/pkcs11.rs index 2926b39f..54f72b55 100644 --- a/crates/auths-core/tests/cases/pkcs11.rs +++ b/crates/auths-core/tests/cases/pkcs11.rs @@ -110,7 +110,7 @@ fn test_pkcs11_key_generate_and_load() { let keyref = Pkcs11KeyRef::new(&fixture.config).unwrap(); let alias = KeyAlias::new("test-key-1").unwrap(); - let did = IdentityDID::new("did:keri:ETEST123"); + let did = IdentityDID::new_unchecked("did:keri:ETEST123"); keyref .store_key(&alias, &did, KeyRole::Primary, &[]) @@ -126,7 +126,7 @@ fn test_pkcs11_list_aliases() { let fixture = skip_without_softhsm!(); let keyref = Pkcs11KeyRef::new(&fixture.config).unwrap(); - let did = IdentityDID::new("did:keri:ELIST"); + let did = IdentityDID::new_unchecked("did:keri:ELIST"); for i in 0..3 { let alias = KeyAlias::new(format!("list-key-{i}")).unwrap(); keyref @@ -151,7 +151,7 @@ fn test_pkcs11_delete_key() { let keyref = Pkcs11KeyRef::new(&fixture.config).unwrap(); let alias = KeyAlias::new("delete-me").unwrap(); - let did = IdentityDID::new("did:keri:EDELETE"); + let did = IdentityDID::new_unchecked("did:keri:EDELETE"); keyref .store_key(&alias, &did, KeyRole::Primary, &[]) .unwrap(); @@ -171,7 +171,7 @@ fn test_pkcs11_sign_and_verify() { let keyref = Pkcs11KeyRef::new(&fixture.config).unwrap(); let alias = KeyAlias::new("sign-key").unwrap(); - let did = IdentityDID::new("did:keri:ESIGN"); + let did = IdentityDID::new_unchecked("did:keri:ESIGN"); keyref .store_key(&alias, &did, KeyRole::Primary, &[]) .unwrap(); @@ -195,7 +195,7 @@ fn test_pkcs11_wrong_pin() { // but operations should fail if let Ok(keyref) = result { let alias = KeyAlias::new("should-fail").unwrap(); - let did = IdentityDID::new("did:keri:EFAIL"); + let did = IdentityDID::new_unchecked("did:keri:EFAIL"); let store_result = keyref.store_key(&alias, &did, KeyRole::Primary, &[]); assert!(store_result.is_err()); } @@ -225,8 +225,8 @@ fn test_pkcs11_list_aliases_for_identity() { let fixture = skip_without_softhsm!(); let keyref = Pkcs11KeyRef::new(&fixture.config).unwrap(); - let did_a = IdentityDID::new("did:keri:EALICE"); - let did_b = IdentityDID::new("did:keri:EBOB"); + let did_a = IdentityDID::new_unchecked("did:keri:EALICE"); + let did_b = IdentityDID::new_unchecked("did:keri:EBOB"); keyref .store_key( diff --git a/crates/auths-id/Cargo.toml b/crates/auths-id/Cargo.toml index 9165a4dd..513b68aa 100644 --- a/crates/auths-id/Cargo.toml +++ b/crates/auths-id/Cargo.toml @@ -13,7 +13,7 @@ categories = ["cryptography", "authentication"] [features] default = ["git-storage"] -git-storage = ["dep:git2", "dep:dirs", "dep:tempfile", "dep:tokio"] +git-storage = ["dep:git2", "dep:dirs", "dep:tempfile", "dep:tokio", "dep:auths-utils"] indexed-storage = ["auths-index"] witness-client = ["dep:auths-infra-http"] test-utils = ["auths-crypto/test-utils", "dep:mockall"] @@ -53,6 +53,7 @@ mockall = { version = "0.13", optional = true } # Optional dependencies auths-index = { workspace = true, optional = true } auths-infra-http = { workspace = true, optional = true } +auths-utils = { workspace = true, optional = true } [dev-dependencies] auths-core = { workspace = true, features = ["test-utils"] } diff --git a/crates/auths-id/src/agent_identity.rs b/crates/auths-id/src/agent_identity.rs index 2d2ee16a..01ef804c 100644 --- a/crates/auths-id/src/agent_identity.rs +++ b/crates/auths-id/src/agent_identity.rs @@ -21,7 +21,7 @@ //! agent_name: "ci-bot".to_string(), //! capabilities: vec!["sign_commit".to_string()], //! expires_in_secs: Some(86400), -//! delegated_by: Some(IdentityDID::new("did:keri:Eabc123")), +//! delegated_by: Some(IdentityDID::new_unchecked("did:keri:Eabc123")), //! storage_mode: AgentStorageMode::Persistent { repo_path: None }, //! }; //! @@ -228,7 +228,7 @@ fn get_or_create_identity( ) -> Result { let mut existing_did: Option = None; let _ = backend.visit_identities(&mut |prefix| { - existing_did = Some(IdentityDID::new(format!("did:keri:{}", prefix))); + existing_did = Some(IdentityDID::new_unchecked(format!("did:keri:{}", prefix))); std::ops::ControlFlow::Break(()) }); if let Some(did) = existing_did { @@ -396,7 +396,7 @@ mod tests { agent_name: "ci-bot".to_string(), capabilities: vec!["sign_commit".to_string(), "pr:create".to_string()], expires_in_secs: Some(86400), - delegated_by: Some(IdentityDID::new("did:keri:Eabc123")), + delegated_by: Some(IdentityDID::new_unchecked("did:keri:Eabc123")), storage_mode: AgentStorageMode::Persistent { repo_path: None }, }; let toml = format_agent_toml("did:keri:Eagent", "agent-key", &config); diff --git a/crates/auths-id/src/attestation/verify.rs b/crates/auths-id/src/attestation/verify.rs index 71d82e9b..59fee9f8 100644 --- a/crates/auths-id/src/attestation/verify.rs +++ b/crates/auths-id/src/attestation/verify.rs @@ -148,8 +148,8 @@ mod tests { Attestation { version: 1, rid: ResourceId::new("test"), - issuer: IdentityDID::new("did:key:zStub"), - subject: DeviceDID::new("did:key:zDevice"), + issuer: IdentityDID::new_unchecked("did:keri:Estub"), + subject: DeviceDID::new_unchecked("did:key:zDevice"), device_public_key: Ed25519PublicKey::from_bytes([0u8; 32]), identity_signature: Ed25519Signature::empty(), device_signature: Ed25519Signature::empty(), diff --git a/crates/auths-id/src/domain/attestation_message.rs b/crates/auths-id/src/domain/attestation_message.rs index 7b66f5aa..74aaa7a0 100644 --- a/crates/auths-id/src/domain/attestation_message.rs +++ b/crates/auths-id/src/domain/attestation_message.rs @@ -46,8 +46,8 @@ mod tests { Attestation { version: 1, rid: ResourceId::new("test-rid"), - issuer: IdentityDID::new("did:keri:EIssuer"), - subject: DeviceDID::new(subject), + issuer: IdentityDID::new_unchecked("did:keri:EIssuer"), + subject: DeviceDID::new_unchecked(subject), device_public_key: Ed25519PublicKey::from_bytes([0u8; 32]), identity_signature: Ed25519Signature::empty(), device_signature: Ed25519Signature::empty(), diff --git a/crates/auths-id/src/keri/inception.rs b/crates/auths-id/src/keri/inception.rs index c05ae969..e3401a4b 100644 --- a/crates/auths-id/src/keri/inception.rs +++ b/crates/auths-id/src/keri/inception.rs @@ -339,7 +339,7 @@ pub fn prefix_to_did(prefix: &str) -> String { /// Extract the prefix from a did:keri DID. /// -/// Prefer [`auths_core::validated_identity_did::ValidatedIdentityDID`] at API boundaries for type safety. +/// Prefer [`auths_verifier::IdentityDID`] at API boundaries for type safety. pub fn did_to_prefix(did: &str) -> Option<&str> { did.strip_prefix("did:keri:") } diff --git a/crates/auths-id/src/policy/mod.rs b/crates/auths-id/src/policy/mod.rs index 1beee802..d83f4c40 100644 --- a/crates/auths-id/src/policy/mod.rs +++ b/crates/auths-id/src/policy/mod.rs @@ -310,12 +310,12 @@ pub fn verify_receipts( Ok(true) => continue, Ok(false) => { return ReceiptVerificationResult::InvalidSignature { - witness_did: DeviceDID::new(&receipt.i), + witness_did: DeviceDID::new_unchecked(&receipt.i), }; } Err(_) => { return ReceiptVerificationResult::InvalidSignature { - witness_did: DeviceDID::new(&receipt.i), + witness_did: DeviceDID::new_unchecked(&receipt.i), }; } } @@ -441,8 +441,8 @@ mod tests { Attestation { version: 1, rid: ResourceId::new("test"), - issuer: IdentityDID::new(issuer), - subject: DeviceDID::new("did:key:subject"), + issuer: IdentityDID::new_unchecked(issuer), + subject: DeviceDID::new_unchecked("did:key:zSubject"), device_public_key: Ed25519PublicKey::from_bytes([0u8; 32]), identity_signature: Ed25519Signature::empty(), device_signature: Ed25519Signature::empty(), diff --git a/crates/auths-id/src/storage/indexed.rs b/crates/auths-id/src/storage/indexed.rs index 5e30a04e..d0f6d98e 100644 --- a/crates/auths-id/src/storage/indexed.rs +++ b/crates/auths-id/src/storage/indexed.rs @@ -153,9 +153,9 @@ impl AttestationSource for IndexedAttestationStorage { // Collect unique device DIDs from the index let mut dids: Vec = active .into_iter() - .map(|a| DeviceDID::new(&a.device_did)) + .map(|a| DeviceDID::new_unchecked(&a.device_did)) .collect(); - dids.sort_by(|a, b| a.0.cmp(&b.0)); + dids.sort_by(|a, b| a.as_str().cmp(b.as_str())); dids.dedup(); Ok(dids) } diff --git a/crates/auths-id/src/storage/layout.rs b/crates/auths-id/src/storage/layout.rs index 9c4d017d..32cb615d 100644 --- a/crates/auths-id/src/storage/layout.rs +++ b/crates/auths-id/src/storage/layout.rs @@ -268,7 +268,10 @@ pub fn sanitize_did_for_ref(did: &str) -> String { #[allow(clippy::disallowed_methods)] // INVARIANT: designated home-dir resolution for repo path pub fn resolve_repo_path(repo_arg: Option) -> Result { match repo_arg { - Some(pathbuf) if !pathbuf.as_os_str().is_empty() => Ok(expand_tilde(&pathbuf)?), + Some(pathbuf) if !pathbuf.as_os_str().is_empty() => { + auths_utils::path::expand_tilde(&pathbuf) + .map_err(|e| StorageError::NotFound(e.to_string())) + } _ => { let home = dirs::home_dir() .ok_or_else(|| StorageError::NotFound("Could not find HOME directory".into()))?; @@ -277,24 +280,6 @@ pub fn resolve_repo_path(repo_arg: Option) -> Result Result { - let s = path.to_string_lossy(); - if s.starts_with("~/") || s == "~" { - let home = dirs::home_dir() - .ok_or_else(|| StorageError::NotFound("Could not find HOME directory".into()))?; - if s == "~" { - Ok(home) - } else { - Ok(home.join(&s[2..])) - } - } else { - Ok(path.to_path_buf()) - } -} - /// Creates a Git namespace prefix string for a given DID. pub fn device_namespace_prefix(did: &str) -> String { format!("refs/namespaces/{}", did_to_nid(did)) @@ -388,44 +373,14 @@ mod tests { } #[cfg(feature = "git-storage")] - mod tilde_expansion { + mod repo_path { use super::super::*; use std::path::PathBuf; - #[test] - fn expand_tilde_expands_home_prefix() { - let path = PathBuf::from("~/.auths"); - let result = expand_tilde(&path).unwrap(); - let home = dirs::home_dir().unwrap(); - assert_eq!(result, home.join(".auths")); - assert!(!result.to_string_lossy().contains('~')); - } - - #[test] - fn expand_tilde_expands_bare_tilde() { - let path = PathBuf::from("~"); - let result = expand_tilde(&path).unwrap(); - let home = dirs::home_dir().unwrap(); - assert_eq!(result, home); - } - - #[test] - fn expand_tilde_leaves_absolute_paths_unchanged() { - let path = PathBuf::from("/tmp/auths"); - let result = expand_tilde(&path).unwrap(); - assert_eq!(result, PathBuf::from("/tmp/auths")); - } - - #[test] - fn expand_tilde_leaves_relative_paths_unchanged() { - let path = PathBuf::from("some/relative/path"); - let result = expand_tilde(&path).unwrap(); - assert_eq!(result, PathBuf::from("some/relative/path")); - } - #[test] fn resolve_repo_path_expands_tilde_in_override() { let result = resolve_repo_path(Some(PathBuf::from("~/.auths"))).unwrap(); + #[allow(clippy::disallowed_methods)] let home = dirs::home_dir().unwrap(); assert_eq!(result, home.join(".auths")); } @@ -433,6 +388,7 @@ mod tests { #[test] fn resolve_repo_path_defaults_to_home_auths() { let result = resolve_repo_path(None).unwrap(); + #[allow(clippy::disallowed_methods)] let home = dirs::home_dir().unwrap(); assert_eq!(result, home.join(".auths")); } diff --git a/crates/auths-id/src/storage/registry/org_member.rs b/crates/auths-id/src/storage/registry/org_member.rs index 19a883e0..476014eb 100644 --- a/crates/auths-id/src/storage/registry/org_member.rs +++ b/crates/auths-id/src/storage/registry/org_member.rs @@ -275,8 +275,8 @@ mod tests { let att = Attestation { version: 1, rid: "test".into(), - issuer: IdentityDID::new("did:key:issuer"), - subject: DeviceDID::new("did:key:subject"), + issuer: IdentityDID::new_unchecked("did:keri:Eissuer"), + subject: DeviceDID::new_unchecked("did:key:zSubject"), device_public_key: Ed25519PublicKey::from_bytes([0u8; 32]), identity_signature: Ed25519Signature::empty(), device_signature: Ed25519Signature::empty(), @@ -300,8 +300,8 @@ mod tests { let att = Attestation { version: 1, rid: "test".into(), - issuer: IdentityDID::new("did:key:issuer"), - subject: DeviceDID::new("did:key:subject"), + issuer: IdentityDID::new_unchecked("did:keri:Eissuer"), + subject: DeviceDID::new_unchecked("did:key:zSubject"), device_public_key: Ed25519PublicKey::from_bytes([0u8; 32]), identity_signature: Ed25519Signature::empty(), device_signature: Ed25519Signature::empty(), @@ -326,8 +326,8 @@ mod tests { let att = Attestation { version: 1, rid: "test".into(), - issuer: IdentityDID::new("did:key:issuer"), - subject: DeviceDID::new("did:key:subject"), + issuer: IdentityDID::new_unchecked("did:keri:Eissuer"), + subject: DeviceDID::new_unchecked("did:key:zSubject"), device_public_key: Ed25519PublicKey::from_bytes([0u8; 32]), identity_signature: Ed25519Signature::empty(), device_signature: Ed25519Signature::empty(), @@ -355,8 +355,8 @@ mod tests { let att = Attestation { version: 1, rid: "test".into(), - issuer: IdentityDID::new("did:key:issuer"), - subject: DeviceDID::new("did:key:subject"), + issuer: IdentityDID::new_unchecked("did:keri:Eissuer"), + subject: DeviceDID::new_unchecked("did:key:zSubject"), device_public_key: Ed25519PublicKey::from_bytes([0u8; 32]), identity_signature: Ed25519Signature::empty(), device_signature: Ed25519Signature::empty(), @@ -381,8 +381,8 @@ mod tests { let att = Attestation { version: 1, rid: "test".into(), - issuer: IdentityDID::new("did:key:issuer"), - subject: DeviceDID::new("did:key:subject"), + issuer: IdentityDID::new_unchecked("did:keri:Eissuer"), + subject: DeviceDID::new_unchecked("did:key:zSubject"), device_public_key: Ed25519PublicKey::from_bytes([0u8; 32]), identity_signature: Ed25519Signature::empty(), device_signature: Ed25519Signature::empty(), @@ -437,8 +437,8 @@ mod tests { let att = Attestation { version: 1, rid: "test".into(), - issuer: IdentityDID::new("did:key:issuer"), - subject: DeviceDID::new("did:key:subject"), + issuer: IdentityDID::new_unchecked("did:keri:Eissuer"), + subject: DeviceDID::new_unchecked("did:key:zSubject"), device_public_key: Ed25519PublicKey::from_bytes([0u8; 32]), identity_signature: Ed25519Signature::empty(), device_signature: Ed25519Signature::empty(), diff --git a/crates/auths-id/src/testing/contracts/registry.rs b/crates/auths-id/src/testing/contracts/registry.rs index f02d8deb..d9f1303d 100644 --- a/crates/auths-id/src/testing/contracts/registry.rs +++ b/crates/auths-id/src/testing/contracts/registry.rs @@ -158,7 +158,7 @@ macro_rules! registry_backend_contract_tests { use auths_verifier::types::DeviceDID; let (store, _guard) = $setup; - let did = DeviceDID::new("did:key:zContractStoreLoad1"); + let did = DeviceDID::new_unchecked("did:key:zContractStoreLoad1"); let att = $crate::testing::fixtures::test_attestation(&did, "did:keri:EIssuer1"); store.store_attestation(&att).unwrap(); let loaded = store.load_attestation(&did).unwrap(); @@ -174,7 +174,7 @@ macro_rules! registry_backend_contract_tests { use auths_verifier::types::DeviceDID; let (store, _guard) = $setup; - let did = DeviceDID::new("did:key:zNotStored99"); + let did = DeviceDID::new_unchecked("did:key:zNotStored99"); let result = store.load_attestation(&did).unwrap(); assert!(result.is_none(), "missing attestation should return None"); } @@ -184,7 +184,7 @@ macro_rules! registry_backend_contract_tests { use auths_verifier::types::DeviceDID; let (store, _guard) = $setup; - let did = DeviceDID::new("did:key:zContractOverwrite1"); + let did = DeviceDID::new_unchecked("did:key:zContractOverwrite1"); let att1 = $crate::testing::fixtures::test_attestation(&did, "did:keri:EIssuer1"); let mut att2 = $crate::testing::fixtures::test_attestation(&did, "did:keri:EIssuer1"); @@ -205,7 +205,7 @@ macro_rules! registry_backend_contract_tests { use auths_verifier::types::DeviceDID; let (store, _guard) = $setup; - let did = DeviceDID::new("did:key:zContractHistory1"); + let did = DeviceDID::new_unchecked("did:key:zContractHistory1"); for i in 0..3u32 { let mut att = @@ -232,7 +232,7 @@ macro_rules! registry_backend_contract_tests { use auths_verifier::types::DeviceDID; let (store, _guard) = $setup; - let did = DeviceDID::new("did:key:zContractHistExit1"); + let did = DeviceDID::new_unchecked("did:key:zContractHistExit1"); for i in 0..3u32 { let mut att = $crate::testing::fixtures::test_attestation(&did, "did:keri:EIssuer1"); @@ -255,8 +255,8 @@ macro_rules! registry_backend_contract_tests { use auths_verifier::types::DeviceDID; let (store, _guard) = $setup; - let did1 = DeviceDID::new("did:key:zContractDev1"); - let did2 = DeviceDID::new("did:key:zContractDev2"); + let did1 = DeviceDID::new_unchecked("did:key:zContractDev1"); + let did2 = DeviceDID::new_unchecked("did:key:zContractDev2"); store .store_attestation(&$crate::testing::fixtures::test_attestation( &did1, @@ -286,7 +286,7 @@ macro_rules! registry_backend_contract_tests { let (store, _guard) = $setup; let org = "ETestOrgPrefix"; - let did = DeviceDID::new("did:key:zMemberContract1"); + let did = DeviceDID::new_unchecked("did:key:zMemberContract1"); let mut att = $crate::testing::fixtures::test_attestation(&did, "did:keri:ETestOrgPrefix"); att.rid = auths_verifier::core::ResourceId::new("org-rid"); @@ -314,7 +314,7 @@ macro_rules! registry_backend_contract_tests { let prefix = event.prefix().clone(); store.append_event(&prefix, &event).unwrap(); - let did = DeviceDID::new("did:key:zContractMeta1"); + let did = DeviceDID::new_unchecked("did:key:zContractMeta1"); store .store_attestation(&$crate::testing::fixtures::test_attestation( &did, diff --git a/crates/auths-id/src/testing/fakes/registry.rs b/crates/auths-id/src/testing/fakes/registry.rs index f82ad627..f5543393 100644 --- a/crates/auths-id/src/testing/fakes/registry.rs +++ b/crates/auths-id/src/testing/fakes/registry.rs @@ -255,8 +255,8 @@ impl RegistryBackend for FakeRegistryBackend { continue; } let entry = OrgMemberEntry { - org: IdentityDID::new(format!("did:keri:{}", org)), - did: DeviceDID::new(member_did_str.clone()), + org: IdentityDID::new_unchecked(format!("did:keri:{}", org)), + did: DeviceDID::new_unchecked(member_did_str.clone()), filename: format!("{}.json", member_did_str.replace(':', "_")), attestation: validate_org_member(org, member_did_str, att), }; @@ -293,13 +293,13 @@ fn validate_org_member( let expected_issuer = format!("did:keri:{}", org); if att.issuer.as_str() != expected_issuer { return Err(MemberInvalidReason::IssuerMismatch { - expected_issuer: IdentityDID::new(expected_issuer), + expected_issuer: IdentityDID::new_unchecked(expected_issuer), actual_issuer: att.issuer.clone(), }); } if att.subject.as_str() != member_did_str { return Err(MemberInvalidReason::SubjectMismatch { - filename_did: DeviceDID::new(member_did_str), + filename_did: DeviceDID::new_unchecked(member_did_str), attestation_subject: att.subject.clone(), }); } diff --git a/crates/auths-id/src/testing/fixtures.rs b/crates/auths-id/src/testing/fixtures.rs index 64e19874..21ee33a7 100644 --- a/crates/auths-id/src/testing/fixtures.rs +++ b/crates/auths-id/src/testing/fixtures.rs @@ -72,7 +72,7 @@ pub fn test_inception_event(key_seed: &str) -> Event { /// /// Usage: /// ```ignore -/// let did = DeviceDID::new("did:key:zTest"); +/// let did = DeviceDID::new_unchecked("did:key:zTest"); /// let att = test_attestation(&did, "did:keri:ETestOrg"); /// backend.store_attestation(&att).unwrap(); /// ``` @@ -80,7 +80,7 @@ pub fn test_attestation(device_did: &DeviceDID, issuer: &str) -> Attestation { Attestation { version: 1, rid: ResourceId::new("test-rid"), - issuer: IdentityDID::new(issuer), + issuer: IdentityDID::new_unchecked(issuer), subject: device_did.clone(), device_public_key: Ed25519PublicKey::from_bytes([0u8; 32]), identity_signature: Ed25519Signature::empty(), diff --git a/crates/auths-id/tests/cases/lifecycle.rs b/crates/auths-id/tests/cases/lifecycle.rs index b810ae02..47f853df 100644 --- a/crates/auths-id/tests/cases/lifecycle.rs +++ b/crates/auths-id/tests/cases/lifecycle.rs @@ -69,7 +69,7 @@ fn generate_device_keypair( let encrypted = auths_core::crypto::signer::encrypt_keypair(device_pkcs8.as_ref(), passphrase) .expect("Failed to encrypt device key"); - let identity_did_typed = IdentityDID::new(identity_did); + let identity_did_typed = IdentityDID::new_unchecked(identity_did); keychain .store_key( &KeyAlias::new_unchecked(device_alias), @@ -102,7 +102,7 @@ fn create_test_attestation( timestamp: Some(now), expires_at: None, }; - let identity_did = IdentityDID::new(identity_did); + let identity_did = IdentityDID::new_unchecked(identity_did); let identity_alias = KeyAlias::new_unchecked(identity_alias); let device_alias = device_alias.map(KeyAlias::new_unchecked); diff --git a/crates/auths-radicle/src/attestation.rs b/crates/auths-radicle/src/attestation.rs index 06038802..076713ac 100644 --- a/crates/auths-radicle/src/attestation.rs +++ b/crates/auths-radicle/src/attestation.rs @@ -227,8 +227,8 @@ impl TryFrom for Attestation { Ok(Attestation { version: 1, rid: ResourceId::new(rad.canonical_payload.rid.to_string()), - issuer: IdentityDID::new(rad.canonical_payload.did.to_string()), - subject: DeviceDID::new(rad.device_did.to_string()), + issuer: IdentityDID::new_unchecked(rad.canonical_payload.did.to_string()), + subject: DeviceDID::new_unchecked(rad.device_did.to_string()), device_public_key: Ed25519PublicKey::try_from_slice(rad.device_public_key.as_ref()) .map_err(|_e| { AttestationConversionError::InvalidPublicKeyLength( @@ -494,8 +494,8 @@ mod tests { let core = Attestation { version: 1, rid: ResourceId::new("rad:z3gqcJUoA1n9HaHKufZs5FCSGazv5"), - issuer: IdentityDID::new("did:keri:EXq5abc"), - subject: DeviceDID::new(device_did.to_string()), + issuer: IdentityDID::new_unchecked("did:keri:EXq5abc"), + subject: DeviceDID::new_unchecked(device_did.to_string()), device_public_key: Ed25519PublicKey::from_bytes(device_pk_bytes), identity_signature: Ed25519Signature::from_bytes([0xCD; 64]), device_signature: Ed25519Signature::from_bytes([0xEF; 64]), diff --git a/crates/auths-radicle/src/storage.rs b/crates/auths-radicle/src/storage.rs index 3fd5803c..870fbb0d 100644 --- a/crates/auths-radicle/src/storage.rs +++ b/crates/auths-radicle/src/storage.rs @@ -184,7 +184,7 @@ impl AuthsStorage for GitRadicleStorage { } fn load_key_state(&self, identity_did: &Did) -> Result { - let did = IdentityDID::new(identity_did.to_string()); + let did = IdentityDID::new_unchecked(identity_did.to_string()); let repo = self.lock_repo(); let events = self.read_kel_events(&repo, &did)?; if events.is_empty() { @@ -204,7 +204,7 @@ impl AuthsStorage for GitRadicleStorage { device_did: &Did, identity_did: &Did, ) -> Result { - let dev_did = DeviceDID::new(device_did.to_string()); + 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); let did_keri_ref = self.layout.device_did_keri_ref(&nid); @@ -261,7 +261,7 @@ impl AuthsStorage for GitRadicleStorage { device_did: &Did, _repo_id: &RepoId, ) -> Result, BridgeError> { - let did_context = IdentityDID::new(format!("lookup:{device_did}")); + 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); diff --git a/crates/auths-radicle/src/verify.rs b/crates/auths-radicle/src/verify.rs index 7f59bf19..adf8cca0 100644 --- a/crates/auths-radicle/src/verify.rs +++ b/crates/auths-radicle/src/verify.rs @@ -269,7 +269,7 @@ impl RadicleAuthsBridge for DefaultBridge { // Step 6: Evaluate policy (revocation, expiry) let decision = evaluate_compiled(&attestation, &self.policy, request.now).map_err(|e| { BridgeError::PolicyEvaluation { - did: IdentityDID::new(identity_did.to_string()), + did: IdentityDID::new_unchecked(identity_did.to_string()), reason: e.to_string(), } })?; @@ -526,7 +526,7 @@ mod tests { .get(identity_did) .cloned() .ok_or_else(|| BridgeError::IdentityLoad { - did: IdentityDID::new(identity_did.to_string()), + did: IdentityDID::new_unchecked(identity_did.to_string()), reason: "Not found".into(), }) } @@ -540,7 +540,7 @@ mod tests { .get(&(device_did.clone(), identity_did.clone())) .cloned() .ok_or_else(|| BridgeError::AttestationLoad { - device_did: VerifierDeviceDID::new(device_did.to_string()), + device_did: VerifierDeviceDID::new_unchecked(device_did.to_string()), reason: "Not found".into(), }) } @@ -590,8 +590,8 @@ mod tests { Attestation { version: 1, rid: ResourceId::new("test"), - issuer: IdentityDID::new(issuer.to_string()), - subject: DeviceDID::new(device_did.to_string()), + issuer: IdentityDID::new_unchecked(issuer.to_string()), + subject: DeviceDID::new_unchecked(device_did.to_string()), device_public_key: Ed25519PublicKey::from_bytes([0u8; 32]), identity_signature: Ed25519Signature::empty(), device_signature: Ed25519Signature::empty(), diff --git a/crates/auths-radicle/tests/cases/helpers.rs b/crates/auths-radicle/tests/cases/helpers.rs index e461c981..87cce41c 100644 --- a/crates/auths-radicle/tests/cases/helpers.rs +++ b/crates/auths-radicle/tests/cases/helpers.rs @@ -64,7 +64,7 @@ impl AuthsStorage for MockStorage { .get(identity_did) .cloned() .ok_or_else(|| BridgeError::IdentityLoad { - did: IdentityDID::new(identity_did.to_string()), + did: IdentityDID::new_unchecked(identity_did.to_string()), reason: "Not found".into(), }) } @@ -78,7 +78,7 @@ impl AuthsStorage for MockStorage { .get(&(device_did.clone(), identity_did.clone())) .cloned() .ok_or_else(|| BridgeError::AttestationLoad { - device_did: DeviceDID::new(device_did.to_string()), + device_did: DeviceDID::new_unchecked(device_did.to_string()), reason: "Not found".into(), }) } @@ -127,8 +127,8 @@ pub fn make_test_attestation( Attestation { version: 1, rid: auths_verifier::core::ResourceId::new(rid.to_string()), - issuer: IdentityDID::new(issuer.to_string()), - subject: DeviceDID::new(device_did.to_string()), + issuer: IdentityDID::new_unchecked(issuer.to_string()), + subject: DeviceDID::new_unchecked(device_did.to_string()), device_public_key: Ed25519PublicKey::from_bytes([0u8; 32]), identity_signature: Ed25519Signature::empty(), device_signature: Ed25519Signature::empty(), diff --git a/crates/auths-radicle/tests/cases/stale_node.rs b/crates/auths-radicle/tests/cases/stale_node.rs index 0bd654c3..f8dd883c 100644 --- a/crates/auths-radicle/tests/cases/stale_node.rs +++ b/crates/auths-radicle/tests/cases/stale_node.rs @@ -137,7 +137,7 @@ fn corrupt_identity_hard_rejected() { } fn load_key_state(&self, _: &Did) -> Result { Err(BridgeError::IdentityCorrupt { - did: auths_verifier::IdentityDID::new("unknown"), + did: auths_verifier::IdentityDID::new_unchecked("did:keri:Eunknown"), reason: "broken chain".into(), }) } diff --git a/crates/auths-radicle/tests/cases/stale_state.rs b/crates/auths-radicle/tests/cases/stale_state.rs index 28642a38..cf9fca38 100644 --- a/crates/auths-radicle/tests/cases/stale_state.rs +++ b/crates/auths-radicle/tests/cases/stale_state.rs @@ -125,7 +125,7 @@ fn corrupt_storage_hard_reject() { _repo_id: &RepoId, ) -> Result, BridgeError> { Err(BridgeError::IdentityCorrupt { - did: auths_verifier::IdentityDID::new("unknown"), + did: auths_verifier::IdentityDID::new_unchecked("did:keri:Eunknown"), reason: "damaged files".into(), }) } diff --git a/crates/auths-sdk/src/device.rs b/crates/auths-sdk/src/device.rs index c1dd1348..88e23489 100644 --- a/crates/auths-sdk/src/device.rs +++ b/crates/auths-sdk/src/device.rs @@ -182,7 +182,7 @@ pub fn extend_device( .map_err(|e| DeviceExtensionError::StorageError(e.into()))?, ); - let device_did_obj = DeviceDID(config.device_did.clone()); + let device_did_obj = DeviceDID::new_unchecked(config.device_did.clone()); let latest = group .latest(&device_did_obj) @@ -231,7 +231,7 @@ pub fn extend_device( ctx.attestation_sink.sync_index(&extended); Ok(DeviceExtensionResult { - device_did: DeviceDID::new(config.device_did), + device_did: DeviceDID::new_unchecked(config.device_did), new_expires_at, previous_expires_at, }) diff --git a/crates/auths-sdk/src/keys.rs b/crates/auths-sdk/src/keys.rs index 9b6a6997..fbc0d238 100644 --- a/crates/auths-sdk/src/keys.rs +++ b/crates/auths-sdk/src/keys.rs @@ -63,7 +63,7 @@ pub struct KeyImportResult { /// ```ignore /// let result = import_seed( /// &seed, &passphrase, "my_key", -/// &IdentityDID::new("did:keri:EXq5abc"), +/// &IdentityDID::new_unchecked("did:keri:EXq5abc"), /// keychain.as_ref(), /// )?; /// ``` diff --git a/crates/auths-sdk/src/pairing/mod.rs b/crates/auths-sdk/src/pairing/mod.rs index 53f4f01f..1567b319 100644 --- a/crates/auths-sdk/src/pairing/mod.rs +++ b/crates/auths-sdk/src/pairing/mod.rs @@ -275,7 +275,7 @@ pub fn verify_device_did(device_pubkey: &[u8; 32], claimed_did: &str) -> Result< use auths_verifier::types::DeviceDID; let derived = DeviceDID::from_ed25519(device_pubkey); - let claimed = DeviceDID::new(claimed_did.to_string()); + let claimed = DeviceDID::new_unchecked(claimed_did.to_string()); if derived != claimed { return Err(PairingError::DidMismatch { @@ -343,7 +343,7 @@ pub fn create_pairing_attestation( }) .collect::, _>>()?; - let target_did = DeviceDID::new(params.device_did_str.to_string()); + let target_did = DeviceDID::new_unchecked(params.device_did_str.to_string()); let secure_signer = StorageSigner::new(Arc::clone(¶ms.key_storage)); let attestation = create_signed_attestation( diff --git a/crates/auths-sdk/src/setup.rs b/crates/auths-sdk/src/setup.rs index 868dcad4..b79c32f7 100644 --- a/crates/auths-sdk/src/setup.rs +++ b/crates/auths-sdk/src/setup.rs @@ -105,7 +105,7 @@ fn initialize_developer( let registered = submit_registration(&config); Ok(DeveloperIdentityResult { - identity_did: IdentityDID::new(controller_did), + identity_did: IdentityDID::new_unchecked(controller_did), device_did, key_alias, platform_claim, @@ -128,7 +128,7 @@ fn initialize_ci( generate_ci_env_block(&key_alias, &config.registry_path, &config.ci_environment); Ok(CiIdentityResult { - identity_did: IdentityDID::new(controller_did), + identity_did: IdentityDID::new_unchecked(controller_did), device_did, env_block, }) @@ -147,7 +147,10 @@ fn initialize_agent( agent_name: config.alias.to_string(), capabilities: cap_strings, expires_in_secs: config.expires_in_secs, - delegated_by: config.parent_identity_did.clone().map(IdentityDID::new), + delegated_by: config + .parent_identity_did + .clone() + .map(IdentityDID::new_unchecked), storage_mode: AgentStorageMode::Persistent { repo_path: Some(config.registry_path.clone()), }, @@ -167,7 +170,7 @@ fn initialize_agent( return Ok(AgentIdentityResult { agent_did: bundle.agent_did, - parent_did: IdentityDID::new(config.parent_identity_did.unwrap_or_default()), + parent_did: IdentityDID::new_unchecked(config.parent_identity_did.unwrap_or_default()), capabilities: config.capabilities, }); } @@ -464,8 +467,10 @@ fn build_agent_identity_proposal( config: &CreateAgentIdentityConfig, ) -> Result { Ok(AgentIdentityResult { - agent_did: IdentityDID::new(format!("did:keri:E", config.alias)), - parent_did: IdentityDID::new(config.parent_identity_did.clone().unwrap_or_default()), + agent_did: IdentityDID::new_unchecked(format!("did:keri:E", config.alias)), + parent_did: IdentityDID::new_unchecked( + config.parent_identity_did.clone().unwrap_or_default(), + ), capabilities: config.capabilities.clone(), }) } diff --git a/crates/auths-sdk/src/workflows/allowed_signers.rs b/crates/auths-sdk/src/workflows/allowed_signers.rs index 84f90d99..19d59b3d 100644 --- a/crates/auths-sdk/src/workflows/allowed_signers.rs +++ b/crates/auths-sdk/src/workflows/allowed_signers.rs @@ -542,14 +542,14 @@ fn parse_entry_line( fn parse_principal(s: &str) -> SignerPrincipal { if let Some(local) = s.strip_suffix("@auths.local") { let did_str = format!("did:key:{}", local); - return SignerPrincipal::DeviceDid(DeviceDID::new(did_str)); + return SignerPrincipal::DeviceDid(DeviceDID::new_unchecked(did_str)); } if s.starts_with("did:key:") { - return SignerPrincipal::DeviceDid(DeviceDID::new(s)); + return SignerPrincipal::DeviceDid(DeviceDID::new_unchecked(s)); } match EmailAddress::new(s) { Ok(addr) => SignerPrincipal::Email(addr), - Err(_) => SignerPrincipal::DeviceDid(DeviceDID::new(s)), + Err(_) => SignerPrincipal::DeviceDid(DeviceDID::new_unchecked(s)), } } @@ -596,7 +596,7 @@ mod tests { #[test] fn principal_display_did() { - let did = DeviceDID::new("did:key:z6MkTest123"); + let did = DeviceDID::new_unchecked("did:key:z6MkTest123"); let p = SignerPrincipal::DeviceDid(did); assert_eq!(p.to_string(), "z6MkTest123@auths.local"); } @@ -607,7 +607,7 @@ mod tests { let parsed = parse_principal(&email_p.to_string()); assert_eq!(parsed, email_p); - let did = DeviceDID::new("did:key:z6MkTest123"); + let did = DeviceDID::new_unchecked("did:key:z6MkTest123"); let did_p = SignerPrincipal::DeviceDid(did); let parsed = parse_principal(&did_p.to_string()); assert_eq!(parsed, did_p); diff --git a/crates/auths-sdk/src/workflows/org.rs b/crates/auths-sdk/src/workflows/org.rs index 18f099d7..2e937a2f 100644 --- a/crates/auths-sdk/src/workflows/org.rs +++ b/crates/auths-sdk/src/workflows/org.rs @@ -335,7 +335,7 @@ pub fn add_organization_member( let now = ctx.clock.now(); let rid = ctx.uuid_provider.new_id().to_string(); - let member_did = DeviceDID::new(&cmd.member_did); + let member_did = DeviceDID::new_unchecked(&cmd.member_did); let meta = AttestationMetadata { note: cmd .note @@ -361,7 +361,7 @@ pub fn add_organization_member( None, parsed_caps, Some(cmd.role), - Some(IdentityDID::new(admin_att.subject.to_string())), + Some(IdentityDID::new_unchecked(admin_att.subject.to_string())), ) .map_err(|e| OrgError::Signing(e.to_string()))?; @@ -408,7 +408,7 @@ pub fn revoke_organization_member( } let now = ctx.clock.now(); - let member_did = DeviceDID::new(&cmd.member_did); + let member_did = DeviceDID::new_unchecked(&cmd.member_did); let revocation = create_signed_revocation( admin_att.rid.as_str(), diff --git a/crates/auths-sdk/tests/cases/allowed_signers.rs b/crates/auths-sdk/tests/cases/allowed_signers.rs index 9123614f..96a2bc53 100644 --- a/crates/auths-sdk/tests/cases/allowed_signers.rs +++ b/crates/auths-sdk/tests/cases/allowed_signers.rs @@ -36,7 +36,7 @@ fn signer_principal_display_email() { #[test] fn signer_principal_display_did() { - let did = DeviceDID::new("did:key:z6MkTest123"); + let did = DeviceDID::new_unchecked("did:key:z6MkTest123"); let p = SignerPrincipal::DeviceDid(did); assert_eq!(p.to_string(), "z6MkTest123@auths.local"); } diff --git a/crates/auths-sdk/tests/cases/org.rs b/crates/auths-sdk/tests/cases/org.rs index b22c59e6..7303ffb4 100644 --- a/crates/auths-sdk/tests/cases/org.rs +++ b/crates/auths-sdk/tests/cases/org.rs @@ -34,7 +34,7 @@ fn admin_pubkey_hex() -> String { } fn org_issuer() -> IdentityDID { - IdentityDID::new(format!("did:keri:{ORG}")) + IdentityDID::new_unchecked(format!("did:keri:{ORG}")) } fn base_admin_attestation() -> Attestation { @@ -42,7 +42,7 @@ fn base_admin_attestation() -> Attestation { version: 1, rid: ResourceId::new("admin-rid-001"), issuer: org_issuer(), - subject: DeviceDID::new(ADMIN_DID), + subject: DeviceDID::new_unchecked(ADMIN_DID), device_public_key: Ed25519PublicKey::from_bytes(ADMIN_PUBKEY), identity_signature: Ed25519Signature::empty(), device_signature: Ed25519Signature::empty(), @@ -64,7 +64,7 @@ fn base_member_attestation() -> Attestation { version: 1, rid: ResourceId::new("member-rid-001"), issuer: org_issuer(), - subject: DeviceDID::new(MEMBER_DID), + subject: DeviceDID::new_unchecked(MEMBER_DID), device_public_key: Ed25519PublicKey::from_bytes(MEMBER_PUBKEY), identity_signature: Ed25519Signature::empty(), device_signature: Ed25519Signature::empty(), @@ -75,7 +75,7 @@ fn base_member_attestation() -> Attestation { payload: None, role: Some(Role::Member), capabilities: vec![Capability::sign_commit()], - delegated_by: Some(IdentityDID::new(ADMIN_DID)), + delegated_by: Some(IdentityDID::new_unchecked(ADMIN_DID)), signer_type: None, environment_claim: None, } diff --git a/crates/auths-storage/src/git/adapter.rs b/crates/auths-storage/src/git/adapter.rs index f633aece..90bc00d1 100644 --- a/crates/auths-storage/src/git/adapter.rs +++ b/crates/auths-storage/src/git/adapter.rs @@ -1330,7 +1330,7 @@ impl RegistryBackend for GitRegistryBackend { if let Err(e) = navigator.visit_dir(&s2_parts, |sanitized_did| { // Convert back to proper DID format let did = unsanitize_did(sanitized_did); - visitor(&DeviceDID::new(did)) + visitor(&DeviceDID::new_unchecked(did)) }) { captured_error.set(Some(e)); return ControlFlow::Break(()); @@ -1434,7 +1434,7 @@ impl RegistryBackend for GitRegistryBackend { // Derive DID from filename let did_str = unsanitize_did(sanitized_did); - let did = DeviceDID::new(did_str.clone()); + let did = DeviceDID::new_unchecked(did_str.clone()); // Read blob and parse attestation let full_path = paths::child(&members_path, filename); @@ -1452,7 +1452,9 @@ impl RegistryBackend for GitRegistryBackend { // Validate issuer matches expected org issuer (hard invariant) } else if att.issuer.as_str() != expected_issuer { Err(MemberInvalidReason::IssuerMismatch { - expected_issuer: IdentityDID::new(expected_issuer.clone()), + expected_issuer: IdentityDID::new_unchecked( + expected_issuer.clone(), + ), actual_issuer: att.issuer.clone(), }) } else { @@ -1466,7 +1468,7 @@ impl RegistryBackend for GitRegistryBackend { }; let entry = OrgMemberEntry { - org: IdentityDID::new(format!("did:keri:{}", org)), + org: IdentityDID::new_unchecked(format!("did:keri:{}", org)), did, filename: filename.to_string(), attestation, @@ -1568,11 +1570,11 @@ impl RegistryBackend for GitRegistryBackend { let members: Vec = indexed .into_iter() .map(|m| MemberView { - did: auths_verifier::types::DeviceDID::new(&m.member_did), + did: auths_verifier::types::DeviceDID::new_unchecked(&m.member_did), status: MemberStatus::Active, role: None, capabilities: vec![], - issuer: auths_core::storage::keychain::IdentityDID::new(m.issuer_did), + issuer: auths_core::storage::keychain::IdentityDID::new_unchecked(m.issuer_did), rid: auths_verifier::core::ResourceId::new(m.rid), revoked_at: m.revoked_at, expires_at: m.expires_at, @@ -2420,11 +2422,11 @@ mod tests { fn store_and_load_attestation() { let (_dir, backend) = setup_test_repo(); - let did = DeviceDID::new("did:key:z6MkTest123"); + let did = DeviceDID::new_unchecked("did:key:z6MkTest123"); let attestation = Attestation { version: 1, rid: ResourceId::new("test-rid"), - issuer: IdentityDID::new("did:keri:EIssuer"), + issuer: IdentityDID::new_unchecked("did:keri:EIssuer"), subject: did.clone(), device_public_key: Ed25519PublicKey::from_bytes([0u8; 32]), identity_signature: Ed25519Signature::empty(), @@ -2453,7 +2455,7 @@ mod tests { #[test] fn load_nonexistent_attestation() { let (_dir, backend) = setup_test_repo(); - let did = DeviceDID::new("did:key:z6MkNonexistent"); + let did = DeviceDID::new_unchecked("did:key:z6MkNonexistent"); let result = backend.load_attestation(&did).unwrap(); assert!(result.is_none()); @@ -2463,13 +2465,13 @@ mod tests { fn store_attestation_overwrites_existing() { // Verify latest-view semantics: store_attestation overwrites existing let (_dir, backend) = setup_test_repo(); - let did = DeviceDID::new("did:key:z6MkTestDevice"); + let did = DeviceDID::new_unchecked("did:key:z6MkTestDevice"); // Store first attestation with rid="original" let original = Attestation { version: 1, rid: ResourceId::new("original"), - issuer: IdentityDID::new("did:keri:EIssuer"), + issuer: IdentityDID::new_unchecked("did:keri:EIssuer"), subject: did.clone(), device_public_key: Ed25519PublicKey::from_bytes([0u8; 32]), identity_signature: Ed25519Signature::empty(), @@ -2496,7 +2498,7 @@ mod tests { let updated = Attestation { version: 1, rid: ResourceId::new("updated"), - issuer: IdentityDID::new("did:keri:EIssuer"), + issuer: IdentityDID::new_unchecked("did:keri:EIssuer"), subject: did.clone(), device_public_key: Ed25519PublicKey::from_bytes([0u8; 32]), identity_signature: Ed25519Signature::empty(), @@ -2523,12 +2525,12 @@ mod tests { #[test] fn replay_same_attestation_rejected() { let (_dir, backend) = setup_test_repo(); - let did = DeviceDID::new("did:key:z6MkReplay1"); + let did = DeviceDID::new_unchecked("did:key:z6MkReplay1"); let att = Attestation { version: 1, rid: ResourceId::new("same-rid"), - issuer: IdentityDID::new("did:keri:EIssuer"), + issuer: IdentityDID::new_unchecked("did:keri:EIssuer"), subject: did.clone(), device_public_key: Ed25519PublicKey::from_bytes([0u8; 32]), identity_signature: Ed25519Signature::empty(), @@ -2558,12 +2560,12 @@ mod tests { #[test] fn replay_older_attestation_rejected() { let (_dir, backend) = setup_test_repo(); - let did = DeviceDID::new("did:key:z6MkReplay2"); + let did = DeviceDID::new_unchecked("did:key:z6MkReplay2"); let newer = Attestation { version: 1, rid: ResourceId::new("rid-newer"), - issuer: IdentityDID::new("did:keri:EIssuer"), + issuer: IdentityDID::new_unchecked("did:keri:EIssuer"), subject: did.clone(), device_public_key: Ed25519PublicKey::from_bytes([0u8; 32]), identity_signature: Ed25519Signature::empty(), @@ -2583,7 +2585,7 @@ mod tests { let older = Attestation { version: 1, rid: ResourceId::new("rid-older"), - issuer: IdentityDID::new("did:keri:EIssuer"), + issuer: IdentityDID::new_unchecked("did:keri:EIssuer"), subject: did.clone(), device_public_key: Ed25519PublicKey::from_bytes([0u8; 32]), identity_signature: Ed25519Signature::empty(), @@ -2608,12 +2610,12 @@ mod tests { #[test] fn newer_attestation_accepted() { let (_dir, backend) = setup_test_repo(); - let did = DeviceDID::new("did:key:z6MkReplay3"); + let did = DeviceDID::new_unchecked("did:key:z6MkReplay3"); let older = Attestation { version: 1, rid: ResourceId::new("rid-old"), - issuer: IdentityDID::new("did:keri:EIssuer"), + issuer: IdentityDID::new_unchecked("did:keri:EIssuer"), subject: did.clone(), device_public_key: Ed25519PublicKey::from_bytes([0u8; 32]), identity_signature: Ed25519Signature::empty(), @@ -2633,7 +2635,7 @@ mod tests { let newer = Attestation { version: 1, rid: ResourceId::new("rid-new"), - issuer: IdentityDID::new("did:keri:EIssuer"), + issuer: IdentityDID::new_unchecked("did:keri:EIssuer"), subject: did.clone(), device_public_key: Ed25519PublicKey::from_bytes([0u8; 32]), identity_signature: Ed25519Signature::empty(), @@ -2660,12 +2662,12 @@ mod tests { #[test] fn replay_revoked_attestation_rejected() { let (_dir, backend) = setup_test_repo(); - let did = DeviceDID::new("did:key:z6MkReplay4"); + let did = DeviceDID::new_unchecked("did:key:z6MkReplay4"); let revoked = Attestation { version: 1, rid: ResourceId::new("rid-revoked"), - issuer: IdentityDID::new("did:keri:EIssuer"), + issuer: IdentityDID::new_unchecked("did:keri:EIssuer"), subject: did.clone(), device_public_key: Ed25519PublicKey::from_bytes([0u8; 32]), identity_signature: Ed25519Signature::empty(), @@ -2685,7 +2687,7 @@ mod tests { let unrevoked_old = Attestation { version: 1, rid: ResourceId::new("rid-unrevoked"), - issuer: IdentityDID::new("did:keri:EIssuer"), + issuer: IdentityDID::new_unchecked("did:keri:EIssuer"), subject: did.clone(), device_public_key: Ed25519PublicKey::from_bytes([0u8; 32]), identity_signature: Ed25519Signature::empty(), @@ -2710,12 +2712,12 @@ mod tests { #[test] fn first_attestation_always_accepted() { let (_dir, backend) = setup_test_repo(); - let did = DeviceDID::new("did:key:z6MkReplay5"); + let did = DeviceDID::new_unchecked("did:key:z6MkReplay5"); let att = Attestation { version: 1, rid: ResourceId::new("first-ever"), - issuer: IdentityDID::new("did:keri:EIssuer"), + issuer: IdentityDID::new_unchecked("did:keri:EIssuer"), subject: did.clone(), device_public_key: Ed25519PublicKey::from_bytes([0u8; 32]), identity_signature: Ed25519Signature::empty(), @@ -2738,12 +2740,12 @@ mod tests { #[test] fn attestation_without_timestamp_rejected_when_existing_has_timestamp() { let (_dir, backend) = setup_test_repo(); - let did = DeviceDID::new("did:key:z6MkReplay6"); + let did = DeviceDID::new_unchecked("did:key:z6MkReplay6"); let with_ts = Attestation { version: 1, rid: ResourceId::new("rid-with-ts"), - issuer: IdentityDID::new("did:keri:EIssuer"), + issuer: IdentityDID::new_unchecked("did:keri:EIssuer"), subject: did.clone(), device_public_key: Ed25519PublicKey::from_bytes([0u8; 32]), identity_signature: Ed25519Signature::empty(), @@ -2763,7 +2765,7 @@ mod tests { let without_ts = Attestation { version: 1, rid: ResourceId::new("rid-no-ts"), - issuer: IdentityDID::new("did:keri:EIssuer"), + issuer: IdentityDID::new_unchecked("did:keri:EIssuer"), subject: did.clone(), device_public_key: Ed25519PublicKey::from_bytes([0u8; 32]), identity_signature: Ed25519Signature::empty(), @@ -2789,13 +2791,13 @@ mod tests { fn visit_devices() { let (_dir, backend) = setup_test_repo(); - let did1 = DeviceDID::new("did:key:z6MkTest1"); - let did2 = DeviceDID::new("did:key:z6MkTest2"); + let did1 = DeviceDID::new_unchecked("did:key:z6MkTest1"); + let did2 = DeviceDID::new_unchecked("did:key:z6MkTest2"); let att1 = Attestation { version: 1, rid: ResourceId::new("rid1"), - issuer: IdentityDID::new("did:keri:EIssuer"), + issuer: IdentityDID::new_unchecked("did:keri:EIssuer"), subject: did1.clone(), device_public_key: Ed25519PublicKey::from_bytes([0u8; 32]), identity_signature: Ed25519Signature::empty(), @@ -2815,7 +2817,7 @@ mod tests { let att2 = Attestation { version: 1, rid: ResourceId::new("rid2"), - issuer: IdentityDID::new("did:keri:EIssuer"), + issuer: IdentityDID::new_unchecked("did:keri:EIssuer"), subject: did2.clone(), device_public_key: Ed25519PublicKey::from_bytes([1u8; 32]), identity_signature: Ed25519Signature::empty(), @@ -2859,12 +2861,12 @@ mod tests { assert_eq!(meta.device_count, 0); // Add device - let did = DeviceDID::new("did:key:z6MkTest"); + let did = DeviceDID::new_unchecked("did:key:z6MkTest"); backend .store_attestation(&Attestation { version: 1, rid: ResourceId::new("rid"), - issuer: IdentityDID::new("did:keri:EIssuer"), + issuer: IdentityDID::new_unchecked("did:keri:EIssuer"), subject: did, device_public_key: Ed25519PublicKey::from_bytes([0u8; 32]), identity_signature: Ed25519Signature::empty(), @@ -2892,12 +2894,12 @@ mod tests { let (_dir, backend) = setup_test_repo(); let org = "EOrg1234567890"; - let member_did = DeviceDID::new("did:key:z6MkMember1"); + let member_did = DeviceDID::new_unchecked("did:key:z6MkMember1"); let member_att = Attestation { version: 1, rid: ResourceId::new("org-member"), - issuer: IdentityDID::new(format!("did:keri:{}", org)), + issuer: IdentityDID::new_unchecked(format!("did:keri:{}", org)), subject: member_did.clone(), device_public_key: Ed25519PublicKey::from_bytes([0u8; 32]), identity_signature: Ed25519Signature::empty(), @@ -2979,11 +2981,11 @@ mod tests { let base_tree = backend.current_tree(&repo).unwrap(); // Create attestation with subject "did:key:z6MkCorrect" - let correct_did = DeviceDID::new("did:key:z6MkCorrect"); + let correct_did = DeviceDID::new_unchecked("did:key:z6MkCorrect"); let att = Attestation { version: 1, rid: ResourceId::new("mismatch-test"), - issuer: IdentityDID::new(format!("did:keri:{}", org)), + issuer: IdentityDID::new_unchecked(format!("did:keri:{}", org)), subject: correct_did, device_public_key: Ed25519PublicKey::from_bytes([0u8; 32]), identity_signature: Ed25519Signature::empty(), @@ -3043,11 +3045,11 @@ mod tests { let org = "EOrg1234567890"; // Store attestation with WRONG issuer (but correct subject) - let member_did = DeviceDID::new("did:key:z6MkWrongIssuer"); + let member_did = DeviceDID::new_unchecked("did:key:z6MkWrongIssuer"); let att = Attestation { version: 1, rid: ResourceId::new("issuer-mismatch-test"), - issuer: IdentityDID::new("did:keri:EDifferentOrg"), // WRONG issuer + issuer: IdentityDID::new_unchecked("did:keri:EDifferentOrg"), // WRONG issuer subject: member_did.clone(), device_public_key: Ed25519PublicKey::from_bytes([0u8; 32]), identity_signature: Ed25519Signature::empty(), @@ -3094,11 +3096,11 @@ mod tests { let org = "EOrg1234567890"; // Store active member - let active_did = DeviceDID::new("did:key:z6MkActive1"); + let active_did = DeviceDID::new_unchecked("did:key:z6MkActive1"); let active_att = Attestation { version: 1, rid: ResourceId::new("active"), - issuer: IdentityDID::new(format!("did:keri:{}", org)), + issuer: IdentityDID::new_unchecked(format!("did:keri:{}", org)), subject: active_did.clone(), device_public_key: Ed25519PublicKey::from_bytes([0u8; 32]), identity_signature: Ed25519Signature::empty(), @@ -3117,11 +3119,11 @@ mod tests { backend.store_org_member(org, &active_att).unwrap(); // Store revoked member - let revoked_did = DeviceDID::new("did:key:z6MkRevoked"); + let revoked_did = DeviceDID::new_unchecked("did:key:z6MkRevoked"); let revoked_att = Attestation { version: 1, rid: ResourceId::new("revoked"), - issuer: IdentityDID::new(format!("did:keri:{}", org)), + issuer: IdentityDID::new_unchecked(format!("did:keri:{}", org)), subject: revoked_did.clone(), device_public_key: Ed25519PublicKey::from_bytes([0u8; 32]), identity_signature: Ed25519Signature::empty(), @@ -3163,11 +3165,11 @@ mod tests { let org = "EOrg1234567890"; // Store revoked member - let revoked_did = DeviceDID::new("did:key:z6MkRevoked"); + let revoked_did = DeviceDID::new_unchecked("did:key:z6MkRevoked"); let revoked_att = Attestation { version: 1, rid: ResourceId::new("revoked"), - issuer: IdentityDID::new(format!("did:keri:{}", org)), + issuer: IdentityDID::new_unchecked(format!("did:keri:{}", org)), subject: revoked_did.clone(), device_public_key: Ed25519PublicKey::from_bytes([0u8; 32]), identity_signature: Ed25519Signature::empty(), @@ -3205,11 +3207,11 @@ mod tests { // Store member with past expiry let past = Utc::now() - Duration::hours(1); - let expired_did = DeviceDID::new("did:key:z6MkExpired"); + let expired_did = DeviceDID::new_unchecked("did:key:z6MkExpired"); let expired_att = Attestation { version: 1, rid: ResourceId::new("expired"), - issuer: IdentityDID::new(format!("did:keri:{}", org)), + issuer: IdentityDID::new_unchecked(format!("did:keri:{}", org)), subject: expired_did.clone(), device_public_key: Ed25519PublicKey::from_bytes([0u8; 32]), identity_signature: Ed25519Signature::empty(), @@ -3249,12 +3251,12 @@ mod tests { let org = "EOrg1234567890"; // Store member from correct org issuer - let org_member_did = DeviceDID::new("did:key:z6MkOrgMember"); + let org_member_did = DeviceDID::new_unchecked("did:key:z6MkOrgMember"); let org_issuer = format!("did:keri:{}", org); let org_att = Attestation { version: 1, rid: ResourceId::new("org"), - issuer: IdentityDID::new(org_issuer.clone()), + issuer: IdentityDID::new_unchecked(org_issuer.clone()), subject: org_member_did.clone(), device_public_key: Ed25519PublicKey::from_bytes([0u8; 32]), identity_signature: Ed25519Signature::empty(), @@ -3273,11 +3275,11 @@ mod tests { backend.store_org_member(org, &org_att).unwrap(); // Store member with WRONG issuer - should be marked Invalid - let wrong_did = DeviceDID::new("did:key:z6MkWrongIssuer"); + let wrong_did = DeviceDID::new_unchecked("did:key:z6MkWrongIssuer"); let wrong_att = Attestation { version: 1, rid: ResourceId::new("wrong"), - issuer: IdentityDID::new("did:keri:EDifferentIssuer"), // WRONG! + issuer: IdentityDID::new_unchecked("did:keri:EDifferentIssuer"), // WRONG! subject: wrong_did.clone(), device_public_key: Ed25519PublicKey::from_bytes([0u8; 32]), identity_signature: Ed25519Signature::empty(), @@ -3331,11 +3333,11 @@ mod tests { let org = "EOrg1234567890"; // Store admin - let admin_did = DeviceDID::new("did:key:z6MkAdminUser"); + let admin_did = DeviceDID::new_unchecked("did:key:z6MkAdminUser"); let admin_att = Attestation { version: 1, rid: ResourceId::new("admin"), - issuer: IdentityDID::new(format!("did:keri:{}", org)), + issuer: IdentityDID::new_unchecked(format!("did:keri:{}", org)), subject: admin_did.clone(), device_public_key: Ed25519PublicKey::from_bytes([0u8; 32]), identity_signature: Ed25519Signature::empty(), @@ -3354,11 +3356,11 @@ mod tests { backend.store_org_member(org, &admin_att).unwrap(); // Store member - let member_did = DeviceDID::new("did:key:z6MkMemberUser"); + let member_did = DeviceDID::new_unchecked("did:key:z6MkMemberUser"); let member_att = Attestation { version: 1, rid: ResourceId::new("member"), - issuer: IdentityDID::new(format!("did:keri:{}", org)), + issuer: IdentityDID::new_unchecked(format!("did:keri:{}", org)), subject: member_did, device_public_key: Ed25519PublicKey::from_bytes([0u8; 32]), identity_signature: Ed25519Signature::empty(), @@ -3399,11 +3401,11 @@ mod tests { let org = "EOrg1234567890"; // Store member with sign_commit capability - let signer_did = DeviceDID::new("did:key:z6MkSigner1"); + let signer_did = DeviceDID::new_unchecked("did:key:z6MkSigner1"); let signer_att = Attestation { version: 1, rid: ResourceId::new("signer"), - issuer: IdentityDID::new(format!("did:keri:{}", org)), + issuer: IdentityDID::new_unchecked(format!("did:keri:{}", org)), subject: signer_did.clone(), device_public_key: Ed25519PublicKey::from_bytes([0u8; 32]), identity_signature: Ed25519Signature::empty(), @@ -3422,11 +3424,11 @@ mod tests { backend.store_org_member(org, &signer_att).unwrap(); // Store member without capabilities - let nocap_did = DeviceDID::new("did:key:z6MkNoCaps1"); + let nocap_did = DeviceDID::new_unchecked("did:key:z6MkNoCaps1"); let nocap_att = Attestation { version: 1, rid: ResourceId::new("nocap"), - issuer: IdentityDID::new(format!("did:keri:{}", org)), + issuer: IdentityDID::new_unchecked(format!("did:keri:{}", org)), subject: nocap_did, device_public_key: Ed25519PublicKey::from_bytes([0u8; 32]), identity_signature: Ed25519Signature::empty(), @@ -3467,11 +3469,11 @@ mod tests { let org = "EOrg1234567890"; // Store member with both capabilities - let both_did = DeviceDID::new("did:key:z6MkBothCaps"); + let both_did = DeviceDID::new_unchecked("did:key:z6MkBothCaps"); let both_att = Attestation { version: 1, rid: ResourceId::new("both"), - issuer: IdentityDID::new(format!("did:keri:{}", org)), + issuer: IdentityDID::new_unchecked(format!("did:keri:{}", org)), subject: both_did.clone(), device_public_key: Ed25519PublicKey::from_bytes([0u8; 32]), identity_signature: Ed25519Signature::empty(), @@ -3490,11 +3492,11 @@ mod tests { backend.store_org_member(org, &both_att).unwrap(); // Store member with only sign_commit - let one_did = DeviceDID::new("did:key:z6MkOneCap1"); + let one_did = DeviceDID::new_unchecked("did:key:z6MkOneCap1"); let one_att = Attestation { version: 1, rid: ResourceId::new("one"), - issuer: IdentityDID::new(format!("did:keri:{}", org)), + issuer: IdentityDID::new_unchecked(format!("did:keri:{}", org)), subject: one_did, device_public_key: Ed25519PublicKey::from_bytes([0u8; 32]), identity_signature: Ed25519Signature::empty(), @@ -3534,11 +3536,11 @@ mod tests { let org = "EOrg1234567890"; // Store valid member - let valid_did = DeviceDID::new("did:key:z6MkValid11"); + let valid_did = DeviceDID::new_unchecked("did:key:z6MkValid11"); let valid_att = Attestation { version: 1, rid: ResourceId::new("valid"), - issuer: IdentityDID::new(format!("did:keri:{}", org)), + issuer: IdentityDID::new_unchecked(format!("did:keri:{}", org)), subject: valid_did.clone(), device_public_key: Ed25519PublicKey::from_bytes([0u8; 32]), identity_signature: Ed25519Signature::empty(), @@ -3620,11 +3622,11 @@ mod tests { ]; for (did_str, revoked_at, expires_at) in &dids { - let did = DeviceDID::new(*did_str); + let did = DeviceDID::new_unchecked(*did_str); let att = Attestation { version: 1, rid: ResourceId::new("test"), - issuer: IdentityDID::new(format!("did:keri:{}", org)), + issuer: IdentityDID::new_unchecked(format!("did:keri:{}", org)), subject: did, device_public_key: Ed25519PublicKey::from_bytes([0u8; 32]), identity_signature: Ed25519Signature::empty(), @@ -3670,11 +3672,11 @@ mod tests { fn attestation_source_load_for_device() { let (_dir, backend) = setup_test_repo(); - let did = DeviceDID::new("did:key:z6MkSourceTest"); + let did = DeviceDID::new_unchecked("did:key:z6MkSourceTest"); let attestation = Attestation { version: 1, rid: ResourceId::new("source-test"), - issuer: IdentityDID::new("did:keri:EIssuer"), + issuer: IdentityDID::new_unchecked("did:keri:EIssuer"), subject: did.clone(), device_public_key: Ed25519PublicKey::from_bytes([0u8; 32]), identity_signature: Ed25519Signature::empty(), @@ -3703,7 +3705,7 @@ mod tests { fn attestation_source_load_for_nonexistent() { let (_dir, backend) = setup_test_repo(); - let did = DeviceDID::new("did:key:z6MkNonexistent"); + let did = DeviceDID::new_unchecked("did:key:z6MkNonexistent"); let loaded = backend.load_attestations_for_device(&did).unwrap(); assert!(loaded.is_empty()); } @@ -3714,11 +3716,11 @@ mod tests { // Store multiple attestations for i in 0..3 { - let did = DeviceDID::new(format!("did:key:z6MkDevice{}", i)); + let did = DeviceDID::new_unchecked(format!("did:key:z6MkDevice{}", i)); let attestation = Attestation { version: 1, rid: ResourceId::new(format!("rid-{}", i)), - issuer: IdentityDID::new("did:keri:EIssuer"), + issuer: IdentityDID::new_unchecked("did:keri:EIssuer"), subject: did, device_public_key: Ed25519PublicKey::from_bytes([i as u8; 32]), identity_signature: Ed25519Signature::empty(), @@ -3747,14 +3749,14 @@ mod tests { // Store attestations for multiple devices let dids: Vec<_> = (0..3) - .map(|i| DeviceDID::new(format!("did:key:z6MkDiscover{}", i))) + .map(|i| DeviceDID::new_unchecked(format!("did:key:z6MkDiscover{}", i))) .collect(); for did in &dids { let attestation = Attestation { version: 1, rid: ResourceId::new("discover-test"), - issuer: IdentityDID::new("did:keri:EIssuer"), + issuer: IdentityDID::new_unchecked("did:keri:EIssuer"), subject: did.clone(), device_public_key: Ed25519PublicKey::from_bytes([0u8; 32]), identity_signature: Ed25519Signature::empty(), @@ -3790,11 +3792,11 @@ mod tests { fn attestation_sink_export() { let (_dir, backend) = setup_test_repo(); - let did = DeviceDID::new("did:key:z6MkSinkTest"); + let did = DeviceDID::new_unchecked("did:key:z6MkSinkTest"); let attestation = Attestation { version: 1, rid: ResourceId::new("sink-test"), - issuer: IdentityDID::new("did:keri:EIssuer"), + issuer: IdentityDID::new_unchecked("did:keri:EIssuer"), subject: did.clone(), device_public_key: Ed25519PublicKey::from_bytes([0u8; 32]), identity_signature: Ed25519Signature::empty(), @@ -3826,13 +3828,13 @@ mod tests { fn attestation_sink_export_updates_existing() { let (_dir, backend) = setup_test_repo(); - let did = DeviceDID::new("did:key:z6MkUpdateTest"); + let did = DeviceDID::new_unchecked("did:key:z6MkUpdateTest"); // First export let attestation1 = Attestation { version: 1, rid: ResourceId::new("original"), - issuer: IdentityDID::new("did:keri:EIssuer"), + issuer: IdentityDID::new_unchecked("did:keri:EIssuer"), subject: did.clone(), device_public_key: Ed25519PublicKey::from_bytes([0u8; 32]), identity_signature: Ed25519Signature::empty(), @@ -3856,7 +3858,7 @@ mod tests { let attestation2 = Attestation { version: 1, rid: ResourceId::new("updated"), - issuer: IdentityDID::new("did:keri:EIssuer"), + issuer: IdentityDID::new_unchecked("did:keri:EIssuer"), subject: did.clone(), device_public_key: Ed25519PublicKey::from_bytes([0u8; 32]), identity_signature: Ed25519Signature::empty(), @@ -4117,8 +4119,8 @@ mod index_consistency_tests { Attestation { version: 1, rid: ResourceId::new(rid), - issuer: IdentityDID::new(format!("did:keri:{}", org_prefix)), - subject: DeviceDID::new(format!("did:key:z6Mk{}", did_suffix)), + issuer: IdentityDID::new_unchecked(format!("did:keri:{}", org_prefix)), + subject: DeviceDID::new_unchecked(format!("did:key:z6Mk{}", did_suffix)), device_public_key: Ed25519PublicKey::from_bytes([0u8; 32]), identity_signature: Ed25519Signature::empty(), device_signature: Ed25519Signature::empty(), @@ -4362,11 +4364,11 @@ mod tenant_isolation_tests { } fn make_test_attestation(device_did: &str) -> Attestation { - let did = DeviceDID::new(device_did); + let did = DeviceDID::new_unchecked(device_did); Attestation { version: 1, rid: ResourceId::new("test-rid"), - issuer: IdentityDID::new("did:keri:EIssuer"), + issuer: IdentityDID::new_unchecked("did:keri:EIssuer"), subject: did, device_public_key: Ed25519PublicKey::from_bytes([0u8; 32]), identity_signature: Ed25519Signature::empty(), diff --git a/crates/auths-storage/src/git/attestation_adapter.rs b/crates/auths-storage/src/git/attestation_adapter.rs index 28a238d4..38c7ee60 100644 --- a/crates/auths-storage/src/git/attestation_adapter.rs +++ b/crates/auths-storage/src/git/attestation_adapter.rs @@ -260,8 +260,8 @@ mod tests { Attestation { version: 1, rid: ResourceId::new(format!("test-rid-{}", seq)), - issuer: IdentityDID::new("did:keri:ETestIssuer"), - subject: DeviceDID::new(subject), + issuer: IdentityDID::new_unchecked("did:keri:ETestIssuer"), + subject: DeviceDID::new_unchecked(subject), device_public_key: Ed25519PublicKey::from_bytes([0u8; 32]), identity_signature: Ed25519Signature::empty(), device_signature: Ed25519Signature::empty(), @@ -325,7 +325,7 @@ mod tests { fn test_attestation_history() { let (_dir, storage) = setup_test_repo(); - let device_did = DeviceDID::new("did:key:zHistoryDevice"); + let device_did = DeviceDID::new_unchecked("did:key:zHistoryDevice"); // Store first attestation let att1 = create_test_attestation("did:key:zHistoryDevice", None); diff --git a/crates/auths-storage/src/git/standalone_attestation.rs b/crates/auths-storage/src/git/standalone_attestation.rs index 7c927387..9aa0f289 100644 --- a/crates/auths-storage/src/git/standalone_attestation.rs +++ b/crates/auths-storage/src/git/standalone_attestation.rs @@ -198,7 +198,7 @@ impl AttestationSource for GitAttestationStorage { sanitized_did, full_ref_name ); - discovered_dids.insert(DeviceDID::new(sanitized_did)); + discovered_dids.insert(DeviceDID::new_unchecked(sanitized_did)); } } } diff --git a/crates/auths-storage/src/git/standalone_identity.rs b/crates/auths-storage/src/git/standalone_identity.rs index 0c3c6ee2..755c027c 100644 --- a/crates/auths-storage/src/git/standalone_identity.rs +++ b/crates/auths-storage/src/git/standalone_identity.rs @@ -66,7 +66,7 @@ impl IdentityStorage for GitIdentityStorage { let stored_data = StoredIdentityData { version: 1, - controller_did: IdentityDID::new(controller_did), + controller_did: IdentityDID::new_unchecked(controller_did), metadata, }; let json_bytes = serde_json::to_vec_pretty(&stored_data)?; diff --git a/crates/auths-utils/Cargo.toml b/crates/auths-utils/Cargo.toml new file mode 100644 index 00000000..99c4ecd7 --- /dev/null +++ b/crates/auths-utils/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "auths-utils" +version.workspace = true +edition = "2024" +rust-version = "1.93" +license.workspace = true +publish = false +description = "Internal shared utilities for the Auths workspace" + +[dependencies] +dirs = "6.0.0" +thiserror.workspace = true + +[lints] +workspace = true diff --git a/crates/auths-utils/src/lib.rs b/crates/auths-utils/src/lib.rs new file mode 100644 index 00000000..61d19351 --- /dev/null +++ b/crates/auths-utils/src/lib.rs @@ -0,0 +1,2 @@ +pub mod path; +pub mod url; diff --git a/crates/auths-utils/src/path.rs b/crates/auths-utils/src/path.rs new file mode 100644 index 00000000..dab2867f --- /dev/null +++ b/crates/auths-utils/src/path.rs @@ -0,0 +1,35 @@ +use std::path::{Path, PathBuf}; + +/// Expand a leading `~/` or bare `~` to the user's home directory. +/// +/// Args: +/// * `path`: A filesystem path that may start with `~`. +/// +/// Usage: +/// ``` +/// # use std::path::Path; +/// # use auths_utils::path::expand_tilde; +/// let expanded = expand_tilde(Path::new("/tmp/foo")).unwrap(); +/// assert_eq!(expanded, Path::new("/tmp/foo")); +/// ``` +#[allow(clippy::disallowed_methods)] // INVARIANT: tilde expansion requires OS home-dir lookup +pub fn expand_tilde(path: &Path) -> Result { + let s = path.to_string_lossy(); + if s.starts_with("~/") || s == "~" { + let home = dirs::home_dir().ok_or(ExpandTildeError::HomeDirNotFound)?; + if s == "~" { + Ok(home) + } else { + Ok(home.join(&s[2..])) + } + } else { + Ok(path.to_path_buf()) + } +} + +#[derive(Debug, thiserror::Error)] +#[non_exhaustive] +pub enum ExpandTildeError { + #[error("could not determine home directory")] + HomeDirNotFound, +} diff --git a/crates/auths-utils/src/url.rs b/crates/auths-utils/src/url.rs new file mode 100644 index 00000000..17877437 --- /dev/null +++ b/crates/auths-utils/src/url.rs @@ -0,0 +1,19 @@ +/// Masks credentials in a URL by replacing the user:password portion with `***`. +/// +/// Args: +/// * `url`: A URL string that may contain embedded credentials. +/// +/// Usage: +/// ``` +/// # use auths_utils::url::mask_url; +/// let masked = mask_url("postgres://user:pass@host/db"); +/// assert_eq!(masked, "postgres://***@host/db"); +/// ``` +pub fn mask_url(url: &str) -> String { + if let Some(at_pos) = url.find('@') + && let Some(scheme_end) = url.find("://") + { + return format!("{}://***@{}", &url[..scheme_end], &url[at_pos + 1..]); + } + url.to_string() +} diff --git a/crates/auths-utils/tests/cases/mod.rs b/crates/auths-utils/tests/cases/mod.rs new file mode 100644 index 00000000..4ec09cb5 --- /dev/null +++ b/crates/auths-utils/tests/cases/mod.rs @@ -0,0 +1,2 @@ +mod path; +mod url; diff --git a/crates/auths-utils/tests/cases/path.rs b/crates/auths-utils/tests/cases/path.rs new file mode 100644 index 00000000..3f641a59 --- /dev/null +++ b/crates/auths-utils/tests/cases/path.rs @@ -0,0 +1,30 @@ +use std::path::PathBuf; + +use auths_utils::path::expand_tilde; + +#[test] +fn tilde_prefix_expands_to_home() { + let result = expand_tilde(&PathBuf::from("~/.auths")).unwrap(); + assert!(!result.to_string_lossy().contains('~')); + assert!(result.ends_with(".auths")); +} + +#[test] +fn bare_tilde_expands_to_home() { + let result = expand_tilde(&PathBuf::from("~")).unwrap(); + #[allow(clippy::disallowed_methods)] + let home = dirs::home_dir().unwrap(); + assert_eq!(result, home); +} + +#[test] +fn absolute_path_unchanged() { + let result = expand_tilde(&PathBuf::from("/tmp/auths")).unwrap(); + assert_eq!(result, PathBuf::from("/tmp/auths")); +} + +#[test] +fn relative_path_unchanged() { + let result = expand_tilde(&PathBuf::from("relative/path")).unwrap(); + assert_eq!(result, PathBuf::from("relative/path")); +} diff --git a/crates/auths-utils/tests/cases/url.rs b/crates/auths-utils/tests/cases/url.rs new file mode 100644 index 00000000..d0886d70 --- /dev/null +++ b/crates/auths-utils/tests/cases/url.rs @@ -0,0 +1,13 @@ +use auths_utils::url::mask_url; + +#[test] +fn url_with_credentials_masked() { + let masked = mask_url("postgres://admin:secret@db.example.com:5432/mydb"); + assert_eq!(masked, "postgres://***@db.example.com:5432/mydb"); +} + +#[test] +fn url_without_at_unchanged() { + let url = "https://example.com/path"; + assert_eq!(mask_url(url), url); +} diff --git a/crates/auths-utils/tests/integration.rs b/crates/auths-utils/tests/integration.rs new file mode 100644 index 00000000..8277b9fa --- /dev/null +++ b/crates/auths-utils/tests/integration.rs @@ -0,0 +1 @@ +mod cases; diff --git a/crates/auths-verifier/src/core.rs b/crates/auths-verifier/src/core.rs index 41026d22..ebf6c024 100644 --- a/crates/auths-verifier/src/core.rs +++ b/crates/auths-verifier/src/core.rs @@ -661,8 +661,8 @@ impl From for String { #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] #[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] pub struct IdentityBundle { - /// The DID of the identity (e.g., "did:keri:...") - pub identity_did: String, + /// The DID of the identity (e.g., `"did:keri:..."`) + pub identity_did: IdentityDID, /// The public key in hex format for signature verification pub public_key_hex: String, /// Chain of attestations linking the signing key to the identity @@ -1297,8 +1297,8 @@ mod tests { let old_json = r#"{ "version": 1, "rid": "test-rid", - "issuer": "did:key:issuer", - "subject": "did:key:subject", + "issuer": "did:keri:Eissuer", + "subject": "did:key:zSubject", "device_public_key": "0102030405060708091011121314151617181920212223242526272829303132", "identity_signature": "00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000", "device_signature": "00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000", @@ -1321,8 +1321,8 @@ mod tests { let att = Attestation { version: 1, rid: ResourceId::new("test-rid"), - issuer: IdentityDID::new("did:key:issuer"), - subject: DeviceDID::new("did:key:subject".to_string()), + issuer: IdentityDID::new_unchecked("did:keri:Eissuer"), + subject: DeviceDID::new_unchecked("did:key:zSubject"), device_public_key: Ed25519PublicKey::from_bytes([0u8; 32]), identity_signature: Ed25519Signature::empty(), device_signature: Ed25519Signature::empty(), @@ -1333,7 +1333,7 @@ mod tests { payload: None, role: Some(Role::Admin), capabilities: vec![Capability::sign_commit(), Capability::manage_members()], - delegated_by: Some(IdentityDID::new("did:key:delegator")), + delegated_by: Some(IdentityDID::new_unchecked("did:keri:Edelegator")), signer_type: None, environment_claim: None, }; @@ -1344,7 +1344,7 @@ mod tests { assert_eq!(parsed["role"], "admin"); assert_eq!(parsed["capabilities"][0], "sign_commit"); assert_eq!(parsed["capabilities"][1], "manage_members"); - assert_eq!(parsed["delegated_by"], "did:key:delegator"); + assert_eq!(parsed["delegated_by"], "did:keri:Edelegator"); } #[test] @@ -1354,8 +1354,8 @@ mod tests { let att = Attestation { version: 1, rid: ResourceId::new("test-rid"), - issuer: IdentityDID::new("did:key:issuer"), - subject: DeviceDID::new("did:key:subject".to_string()), + issuer: IdentityDID::new_unchecked("did:keri:Eissuer"), + subject: DeviceDID::new_unchecked("did:key:zSubject"), device_public_key: Ed25519PublicKey::from_bytes([0u8; 32]), identity_signature: Ed25519Signature::empty(), device_signature: Ed25519Signature::empty(), @@ -1387,8 +1387,8 @@ mod tests { let original = Attestation { version: 1, rid: ResourceId::new("test-rid"), - issuer: IdentityDID::new("did:key:issuer"), - subject: DeviceDID::new("did:key:subject".to_string()), + issuer: IdentityDID::new_unchecked("did:keri:Eissuer"), + subject: DeviceDID::new_unchecked("did:key:zSubject"), device_public_key: Ed25519PublicKey::from_bytes([0u8; 32]), identity_signature: Ed25519Signature::empty(), device_signature: Ed25519Signature::empty(), @@ -1399,7 +1399,7 @@ mod tests { payload: None, role: Some(Role::Member), capabilities: vec![Capability::sign_commit(), Capability::sign_release()], - delegated_by: Some(IdentityDID::new("did:key:admin")), + delegated_by: Some(IdentityDID::new_unchecked("did:keri:Eadmin")), signer_type: None, environment_claim: None, }; @@ -1533,7 +1533,7 @@ mod tests { #[test] fn identity_bundle_serializes_correctly() { let bundle = IdentityBundle { - identity_did: "did:keri:test123".to_string(), + identity_did: IdentityDID::new_unchecked("did:keri:test123"), public_key_hex: "aabbccdd".to_string(), attestation_chain: vec![], bundle_timestamp: DateTime::parse_from_rfc3339("2099-01-01T00:00:00Z") @@ -1562,7 +1562,7 @@ mod tests { let bundle: IdentityBundle = serde_json::from_str(json).unwrap(); - assert_eq!(bundle.identity_did, "did:keri:abc123"); + assert_eq!(bundle.identity_did.as_str(), "did:keri:abc123"); assert_eq!(bundle.public_key_hex, "112233"); assert!(bundle.attestation_chain.is_empty()); } @@ -1574,8 +1574,8 @@ mod tests { let attestation = Attestation { version: 1, rid: ResourceId::new("test-rid"), - issuer: IdentityDID::new("did:key:issuer"), - subject: DeviceDID::new("did:key:subject".to_string()), + issuer: IdentityDID::new_unchecked("did:keri:Eissuer"), + subject: DeviceDID::new_unchecked("did:key:zSubject"), device_public_key: Ed25519PublicKey::from_bytes([0u8; 32]), identity_signature: Ed25519Signature::empty(), device_signature: Ed25519Signature::empty(), @@ -1592,7 +1592,7 @@ mod tests { }; let original = IdentityBundle { - identity_did: "did:keri:example".to_string(), + identity_did: IdentityDID::new_unchecked("did:keri:Eexample"), public_key_hex: "deadbeef".to_string(), attestation_chain: vec![attestation], bundle_timestamp: DateTime::parse_from_rfc3339("2099-01-01T00:00:00Z") diff --git a/crates/auths-verifier/src/ffi.rs b/crates/auths-verifier/src/ffi.rs index 4ab1a9bf..454cc974 100644 --- a/crates/auths-verifier/src/ffi.rs +++ b/crates/auths-verifier/src/ffi.rs @@ -497,7 +497,16 @@ pub unsafe extern "C" fn ffi_verify_device_authorization_json( return ERR_VERIFY_JSON_PARSE; } }; - let device_did = DeviceDID::new(device_did_str); + let device_did = match DeviceDID::parse(device_did_str) { + Ok(d) => d, + Err(e) => { + error!( + "FFI verify_device_authorization_json: invalid device DID: {}", + e + ); + return ERR_VERIFY_JSON_PARSE; + } + }; let attestations: Vec = match serde_json::from_slice(chain_json) { Ok(a) => a, diff --git a/crates/auths-verifier/src/types.rs b/crates/auths-verifier/src/types.rs index c5891e85..a56838fb 100644 --- a/crates/auths-verifier/src/types.rs +++ b/crates/auths-verifier/src/types.rs @@ -135,26 +135,22 @@ use std::str::FromStr; /// Strongly-typed wrapper for identity DIDs (e.g., `"did:keri:E..."`). /// /// Usage: -/// ```ignore -/// let did = IdentityDID::new("did:keri:Eabc123"); +/// ```rust +/// # use auths_verifier::IdentityDID; +/// let did = IdentityDID::parse("did:keri:Eabc123").unwrap(); /// assert_eq!(did.as_str(), "did:keri:Eabc123"); /// /// let s: String = did.into_inner(); /// ``` -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord, Hash)] +#[derive(Debug, Clone, Serialize, PartialEq, Eq, PartialOrd, Ord, Hash)] #[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] #[repr(transparent)] -pub struct IdentityDID(pub String); +pub struct IdentityDID(String); impl IdentityDID { - /// Create a new `IdentityDID` from a raw string. - pub fn new>(s: S) -> Self { - Self(s.into()) - } - /// Wraps a DID string without validation (for trusted internal paths). - pub fn new_unchecked(s: String) -> Self { - Self(s) + pub fn new_unchecked>(s: S) -> Self { + Self(s.into()) } /// Validates and parses a `did:keri:` string into an `IdentityDID`. @@ -231,15 +227,19 @@ impl FromStr for IdentityDID { } } -impl From<&str> for IdentityDID { - fn from(s: &str) -> Self { - Self(s.to_string()) +impl TryFrom<&str> for IdentityDID { + type Error = DidParseError; + + fn try_from(s: &str) -> Result { + Self::parse(s) } } -impl From for IdentityDID { - fn from(s: String) -> Self { - Self(s) +impl TryFrom for IdentityDID { + type Error = DidParseError; + + fn try_from(s: String) -> Result { + Self::parse(&s) } } @@ -249,6 +249,16 @@ impl From for String { } } +impl<'de> serde::Deserialize<'de> for IdentityDID { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let s = String::deserialize(deserializer)?; + Self::parse(&s).map_err(serde::de::Error::custom) + } +} + impl Deref for IdentityDID { type Target = str; @@ -298,13 +308,13 @@ impl PartialEq for &str { // ============================================================================ /// Wrapper around a device DID string that ensures Git-safe ref formatting. -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord, Hash)] +#[derive(Debug, Clone, Serialize, PartialEq, Eq, PartialOrd, Ord, Hash)] #[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] -pub struct DeviceDID(pub String); +pub struct DeviceDID(String); impl DeviceDID { - /// Create a new `DeviceDID` from a raw string. - pub fn new>(s: S) -> Self { + /// Wraps a DID string without validation (for trusted internal paths). + pub fn new_unchecked>(s: S) -> Self { DeviceDID(s.into()) } @@ -382,20 +392,38 @@ impl FromStr for DeviceDID { } } -// Allow `DeviceDID::from("did:key:abc")` and vice versa -impl From<&str> for DeviceDID { - fn from(s: &str) -> Self { - DeviceDID(s.to_string()) +impl TryFrom<&str> for DeviceDID { + type Error = DidParseError; + + fn try_from(s: &str) -> Result { + Self::parse(s) + } +} + +impl TryFrom for DeviceDID { + type Error = DidParseError; + + fn try_from(s: String) -> Result { + Self::parse(&s) + } +} + +impl From for String { + fn from(did: DeviceDID) -> String { + did.0 } } -impl From for DeviceDID { - fn from(s: String) -> Self { - DeviceDID(s) +impl<'de> serde::Deserialize<'de> for DeviceDID { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let s = String::deserialize(deserializer)?; + Self::parse(&s).map_err(serde::de::Error::custom) } } -// Optionally deref to &str impl Deref for DeviceDID { type Target = str; diff --git a/crates/auths-verifier/src/verify.rs b/crates/auths-verifier/src/verify.rs index c66e2b76..259fac1e 100644 --- a/crates/auths-verifier/src/verify.rs +++ b/crates/auths-verifier/src/verify.rs @@ -605,8 +605,8 @@ mod tests { let mut att = Attestation { version: 1, rid: ResourceId::new("test-rid"), - issuer: IdentityDID::new(issuer_did), - subject: DeviceDID::new(subject_did), + issuer: IdentityDID::new_unchecked(issuer_did), + subject: DeviceDID::new_unchecked(subject_did), device_public_key: Ed25519PublicKey::from_bytes(device_pk), identity_signature: Ed25519Signature::empty(), device_signature: Ed25519Signature::empty(), @@ -1026,7 +1026,7 @@ mod tests { let root_did = auths_crypto::ed25519_pubkey_to_did_key(&root_pk); let (device_kp, device_pk) = create_test_keypair(&[2u8; 32]); let device_did_str = auths_crypto::ed25519_pubkey_to_did_key(&device_pk); - let device_did = DeviceDID::new(&device_did_str); + let device_did = DeviceDID::new_unchecked(&device_did_str); let att = create_signed_attestation( &root_kp, @@ -1051,7 +1051,7 @@ mod tests { let root_did = auths_crypto::ed25519_pubkey_to_did_key(&root_pk); let (_, device_pk) = create_test_keypair(&[2u8; 32]); let device_did_str = auths_crypto::ed25519_pubkey_to_did_key(&device_pk); - let device_did = DeviceDID::new(&device_did_str); + let device_did = DeviceDID::new_unchecked(&device_did_str); assert!(!is_device_listed(&root_did, &device_did, &[], fixed_now())); } @@ -1062,7 +1062,7 @@ mod tests { let root_did = auths_crypto::ed25519_pubkey_to_did_key(&root_pk); let (device_kp, device_pk) = create_test_keypair(&[2u8; 32]); let device_did_str = auths_crypto::ed25519_pubkey_to_did_key(&device_pk); - let device_did = DeviceDID::new(&device_did_str); + let device_did = DeviceDID::new_unchecked(&device_did_str); let att = create_signed_attestation( &root_kp, @@ -1087,7 +1087,7 @@ mod tests { let root_did = auths_crypto::ed25519_pubkey_to_did_key(&root_pk); let (device_kp, device_pk) = create_test_keypair(&[2u8; 32]); let device_did_str = auths_crypto::ed25519_pubkey_to_did_key(&device_pk); - let device_did = DeviceDID::new(&device_did_str); + let device_did = DeviceDID::new_unchecked(&device_did_str); let att = create_signed_attestation( &root_kp, @@ -1112,7 +1112,7 @@ mod tests { let root_did = auths_crypto::ed25519_pubkey_to_did_key(&root_pk); let (device_kp, device_pk) = create_test_keypair(&[2u8; 32]); let device_did_str = auths_crypto::ed25519_pubkey_to_did_key(&device_pk); - let device_did = DeviceDID::new(&device_did_str); + let device_did = DeviceDID::new_unchecked(&device_did_str); let att_expired = verified(create_signed_attestation( &root_kp, @@ -1155,7 +1155,7 @@ mod tests { let other_did = auths_crypto::ed25519_pubkey_to_did_key(&other_pk); let (device_kp, device_pk) = create_test_keypair(&[2u8; 32]); let device_did_str = auths_crypto::ed25519_pubkey_to_did_key(&device_pk); - let device_did = DeviceDID::new(&device_did_str); + let device_did = DeviceDID::new_unchecked(&device_did_str); let att = create_signed_attestation( &other_kp, @@ -1181,7 +1181,7 @@ mod tests { let device_did_str = auths_crypto::ed25519_pubkey_to_did_key(&device_pk); let (_, other_device_pk) = create_test_keypair(&[4u8; 32]); let other_device_did_str = auths_crypto::ed25519_pubkey_to_did_key(&other_device_pk); - let other_device_did = DeviceDID::new(&other_device_did_str); + let other_device_did = DeviceDID::new_unchecked(&other_device_did_str); let att = create_signed_attestation( &root_kp, @@ -1213,8 +1213,8 @@ mod tests { let mut att = Attestation { version: 1, rid: ResourceId::new("test-rid"), - issuer: IdentityDID::new(issuer_did), - subject: DeviceDID::new(subject_did), + issuer: IdentityDID::new_unchecked(issuer_did), + subject: DeviceDID::new_unchecked(subject_did), device_public_key: Ed25519PublicKey::from_bytes(device_pk), identity_signature: Ed25519Signature::empty(), device_signature: Ed25519Signature::empty(), @@ -1519,7 +1519,7 @@ mod tests { .is_ok() ); - att.delegated_by = Some(IdentityDID::new("did:key:attacker")); + att.delegated_by = Some(IdentityDID::new_unchecked("did:keri:Eattacker")); let result = test_verifier().verify_with_keys(&att, &root_pk).await; assert!( result.is_err(), @@ -1563,8 +1563,8 @@ mod tests { let mut att = Attestation { version: 1, rid: ResourceId::new("test-rid"), - issuer: IdentityDID::new(issuer_did), - subject: DeviceDID::new(subject_did), + issuer: IdentityDID::new_unchecked(issuer_did), + subject: DeviceDID::new_unchecked(subject_did), device_public_key: Ed25519PublicKey::from_bytes(device_pk), identity_signature: Ed25519Signature::empty(), device_signature: Ed25519Signature::empty(), @@ -1693,7 +1693,7 @@ mod tests { let root_did = auths_crypto::ed25519_pubkey_to_did_key(&root_pk); let (device_kp, device_pk) = create_test_keypair(&[2u8; 32]); let device_did_str = auths_crypto::ed25519_pubkey_to_did_key(&device_pk); - let device_did = DeviceDID::new(&device_did_str); + let device_did = DeviceDID::new_unchecked(&device_did_str); let att = create_signed_attestation( &root_kp, @@ -1720,7 +1720,7 @@ mod tests { let root_did = auths_crypto::ed25519_pubkey_to_did_key(&root_pk); let (_, device_pk) = create_test_keypair(&[2u8; 32]); let device_did_str = auths_crypto::ed25519_pubkey_to_did_key(&device_pk); - let device_did = DeviceDID::new(&device_did_str); + let device_did = DeviceDID::new_unchecked(&device_did_str); let result = test_verifier() .verify_device_authorization(&root_did, &device_did, &[], &root_pk) @@ -1742,7 +1742,7 @@ mod tests { let root_did = auths_crypto::ed25519_pubkey_to_did_key(&root_pk); let (device_kp, device_pk) = create_test_keypair(&[2u8; 32]); let device_did_str = auths_crypto::ed25519_pubkey_to_did_key(&device_pk); - let device_did = DeviceDID::new(&device_did_str); + let device_did = DeviceDID::new_unchecked(&device_did_str); let mut att = create_signed_attestation( &root_kp, @@ -1774,7 +1774,7 @@ mod tests { let root_did = auths_crypto::ed25519_pubkey_to_did_key(&root_pk); let (device_kp, device_pk) = create_test_keypair(&[2u8; 32]); let device_did_str = auths_crypto::ed25519_pubkey_to_did_key(&device_pk); - let device_did = DeviceDID::new(&device_did_str); + let device_did = DeviceDID::new_unchecked(&device_did_str); let (_, wrong_pk) = create_test_keypair(&[99u8; 32]); let att = create_signed_attestation( @@ -1804,7 +1804,7 @@ mod tests { let root_did = auths_crypto::ed25519_pubkey_to_did_key(&root_pk); let (device_kp, device_pk) = create_test_keypair(&[2u8; 32]); let device_did_str = auths_crypto::ed25519_pubkey_to_did_key(&device_pk); - let device_did = DeviceDID::new(&device_did_str); + let device_did = DeviceDID::new_unchecked(&device_did_str); let att_expired = create_signed_attestation( &root_kp, diff --git a/crates/auths-verifier/tests/cases/did_parsing.rs b/crates/auths-verifier/tests/cases/did_parsing.rs index 7f57b4a4..2a663890 100644 --- a/crates/auths-verifier/tests/cases/did_parsing.rs +++ b/crates/auths-verifier/tests/cases/did_parsing.rs @@ -1,4 +1,5 @@ use auths_verifier::{DeviceDID, DidParseError, IdentityDID}; +use std::convert::TryFrom; // ============================================================================ // DeviceDID::parse() @@ -183,3 +184,128 @@ fn did_parse_error_display_is_useful() { let err = DidParseError::EmptyIdentifier; assert!(err.to_string().contains("empty")); } + +// ============================================================================ +// TryFrom round-trips +// ============================================================================ + +#[test] +fn device_did_try_from_string_matches_parse() { + let s = "did:key:z6MkhaXgBZDvotDkL5257faiztiGiC2QtKLGpbnnEGta2doK".to_string(); + let from_parse = DeviceDID::parse(&s).unwrap(); + let from_try: DeviceDID = DeviceDID::try_from(s).unwrap(); + assert_eq!(from_parse, from_try); +} + +#[test] +fn device_did_try_from_str_matches_parse() { + let s = "did:key:z6MkTest"; + let from_parse = DeviceDID::parse(s).unwrap(); + let from_try: DeviceDID = DeviceDID::try_from(s).unwrap(); + assert_eq!(from_parse, from_try); +} + +#[test] +fn device_did_try_from_invalid_returns_error() { + assert!(DeviceDID::try_from("garbage".to_string()).is_err()); + assert!(DeviceDID::try_from("garbage").is_err()); +} + +#[test] +fn identity_did_try_from_string_matches_parse() { + let s = "did:keri:ETest123".to_string(); + let from_parse = IdentityDID::parse(&s).unwrap(); + let from_try: IdentityDID = IdentityDID::try_from(s).unwrap(); + assert_eq!(from_parse, from_try); +} + +#[test] +fn identity_did_try_from_str_matches_parse() { + let s = "did:keri:ETest123"; + let from_parse = IdentityDID::parse(s).unwrap(); + let from_try: IdentityDID = IdentityDID::try_from(s).unwrap(); + assert_eq!(from_parse, from_try); +} + +#[test] +fn identity_did_try_from_invalid_returns_error() { + assert!(IdentityDID::try_from("did:key:z6Mk".to_string()).is_err()); + assert!(IdentityDID::try_from("garbage").is_err()); +} + +// ============================================================================ +// Serde round-trips +// ============================================================================ + +#[test] +fn device_did_serde_roundtrip() { + let did = DeviceDID::parse("did:key:z6MkhaXgBZDvotDkL5257faiztiGiC2QtKLGpbnnEGta2doK").unwrap(); + let json = serde_json::to_string(&did).unwrap(); + assert_eq!( + json, + "\"did:key:z6MkhaXgBZDvotDkL5257faiztiGiC2QtKLGpbnnEGta2doK\"" + ); + let parsed: DeviceDID = serde_json::from_str(&json).unwrap(); + assert_eq!(did, parsed); +} + +#[test] +fn identity_did_serde_roundtrip() { + let did = IdentityDID::parse("did:keri:EOrg123").unwrap(); + let json = serde_json::to_string(&did).unwrap(); + assert_eq!(json, "\"did:keri:EOrg123\""); + let parsed: IdentityDID = serde_json::from_str(&json).unwrap(); + assert_eq!(did, parsed); +} + +#[test] +fn device_did_serde_rejects_invalid() { + let result: Result = serde_json::from_str("\"garbage\""); + assert!(result.is_err()); +} + +#[test] +fn device_did_serde_rejects_wrong_prefix() { + let result: Result = serde_json::from_str("\"did:keri:EPrefix\""); + assert!(result.is_err()); +} + +#[test] +fn identity_did_serde_rejects_invalid() { + let result: Result = serde_json::from_str("\"garbage\""); + assert!(result.is_err()); +} + +#[test] +fn identity_did_serde_rejects_did_key() { + let result: Result = serde_json::from_str("\"did:key:z6MkTest\""); + assert!(result.is_err()); +} + +// ============================================================================ +// as_str() accessor +// ============================================================================ + +#[test] +fn device_did_as_str_matches_original() { + let s = "did:key:z6MkTest"; + let did = DeviceDID::parse(s).unwrap(); + assert_eq!(did.as_str(), s); +} + +#[test] +fn identity_did_as_str_matches_original() { + let s = "did:keri:ETest"; + let did = IdentityDID::parse(s).unwrap(); + assert_eq!(did.as_str(), s); +} + +// ============================================================================ +// DidParseError implements std::error::Error +// ============================================================================ + +#[test] +fn did_parse_error_is_std_error() { + let err = DidParseError::InvalidDevicePrefix("bad".to_string()); + let _: &dyn std::error::Error = &err; +} diff --git a/crates/auths-verifier/tests/cases/expiration_skew.rs b/crates/auths-verifier/tests/cases/expiration_skew.rs index 1a38e79c..e5d2a0bf 100644 --- a/crates/auths-verifier/tests/cases/expiration_skew.rs +++ b/crates/auths-verifier/tests/cases/expiration_skew.rs @@ -21,8 +21,8 @@ fn create_signed_attestation( let mut att = Attestation { version: 1, rid: ResourceId::new("test-rid"), - issuer: IdentityDID::new(issuer_did), - subject: DeviceDID::new(subject_did), + issuer: IdentityDID::new_unchecked(issuer_did), + subject: DeviceDID::new_unchecked(subject_did), device_public_key: Ed25519PublicKey::from_bytes(device_pk), identity_signature: Ed25519Signature::empty(), device_signature: Ed25519Signature::empty(), diff --git a/crates/auths-verifier/tests/cases/kel_verification.rs b/crates/auths-verifier/tests/cases/kel_verification.rs index e8e8c10e..2fd6e5c2 100644 --- a/crates/auths-verifier/tests/cases/kel_verification.rs +++ b/crates/auths-verifier/tests/cases/kel_verification.rs @@ -160,8 +160,8 @@ fn minimal_attestation(issuer: &str, subject: &str) -> auths_verifier::core::Att auths_verifier::core::Attestation { version: 1, rid: auths_verifier::ResourceId::new(""), - issuer: auths_verifier::IdentityDID(issuer.to_string()), - subject: auths_verifier::DeviceDID::new(subject), + issuer: auths_verifier::IdentityDID::new_unchecked(issuer.to_string()), + subject: auths_verifier::DeviceDID::new_unchecked(subject), device_public_key: auths_verifier::Ed25519PublicKey::from_bytes([0u8; 32]), identity_signature: auths_verifier::core::Ed25519Signature::empty(), device_signature: auths_verifier::core::Ed25519Signature::empty(), diff --git a/crates/auths-verifier/tests/cases/proptest_core.rs b/crates/auths-verifier/tests/cases/proptest_core.rs index efb1a3dc..bea0d765 100644 --- a/crates/auths-verifier/tests/cases/proptest_core.rs +++ b/crates/auths-verifier/tests/cases/proptest_core.rs @@ -23,12 +23,12 @@ fn arb_did() -> impl Strategy { /// Generate arbitrary IdentityDID fn arb_identity_did() -> impl Strategy { - arb_did().prop_map(IdentityDID::new) + arb_did().prop_map(IdentityDID::new_unchecked) } /// Generate arbitrary DeviceDID fn arb_device_did() -> impl Strategy { - arb_did().prop_map(DeviceDID::new) + arb_did().prop_map(DeviceDID::new_unchecked) } /// Generate arbitrary 32-byte public key @@ -237,7 +237,7 @@ proptest! { /// Test that DID strings maintain format through DeviceDID #[test] fn device_did_preserves_string(did_str in arb_did()) { - let device_did = DeviceDID::new(did_str.clone()); + let device_did = DeviceDID::new_unchecked(did_str.clone()); prop_assert_eq!(device_did.as_str(), &did_str); } diff --git a/crates/auths-verifier/tests/cases/revocation_adversarial.rs b/crates/auths-verifier/tests/cases/revocation_adversarial.rs index 0df0cca1..1e4eb4f3 100644 --- a/crates/auths-verifier/tests/cases/revocation_adversarial.rs +++ b/crates/auths-verifier/tests/cases/revocation_adversarial.rs @@ -24,8 +24,8 @@ fn create_signed_attestation( let mut att = Attestation { version: 1, rid: ResourceId::new("test-rid"), - issuer: IdentityDID::new(issuer_did), - subject: DeviceDID::new(subject_did), + issuer: IdentityDID::new_unchecked(issuer_did), + subject: DeviceDID::new_unchecked(subject_did), device_public_key: Ed25519PublicKey::from_bytes(device_pk), identity_signature: Ed25519Signature::empty(), device_signature: Ed25519Signature::empty(), diff --git a/crates/auths-verifier/tests/cases/serialization_pinning.rs b/crates/auths-verifier/tests/cases/serialization_pinning.rs index b6427884..533395f7 100644 --- a/crates/auths-verifier/tests/cases/serialization_pinning.rs +++ b/crates/auths-verifier/tests/cases/serialization_pinning.rs @@ -188,8 +188,8 @@ fn environment_claim_excluded_from_canonical_form() { let att = Attestation { version: 1, rid: ResourceId::new("test-rid"), - issuer: IdentityDID::new("did:keri:ETest"), - subject: DeviceDID::new("did:key:z6Mk..."), + issuer: IdentityDID::new_unchecked("did:keri:ETest"), + subject: DeviceDID::new_unchecked("did:key:z6Mk..."), device_public_key: Ed25519PublicKey::from_bytes([0u8; 32]), identity_signature: Ed25519Signature::empty(), device_signature: Ed25519Signature::empty(), @@ -262,8 +262,8 @@ fn environment_claim_roundtrips_through_json() { let att = Attestation { version: 1, rid: ResourceId::new("test-rid"), - issuer: IdentityDID::new("did:keri:ETest"), - subject: DeviceDID::new("did:key:z6Mk..."), + issuer: IdentityDID::new_unchecked("did:keri:ETest"), + subject: DeviceDID::new_unchecked("did:key:z6Mk..."), device_public_key: Ed25519PublicKey::from_bytes([0u8; 32]), identity_signature: Ed25519Signature::empty(), device_signature: Ed25519Signature::empty(), diff --git a/docs/plans/launch_cleaning.md b/docs/plans/launch_cleaning.md index 08df5694..3704a11f 100644 --- a/docs/plans/launch_cleaning.md +++ b/docs/plans/launch_cleaning.md @@ -257,7 +257,7 @@ These are the features that separate "impressive developer tool" from "enterpris --- -#### Epic 2: `Utc::now()` Injection — Complete Clock Discipline +~~#### Epic 2: `Utc::now()` Injection — Complete Clock Discipline~~ **Why it matters:** Every expiry check, freeze check, and token validity check in the CLI is currently untestable. This is a launch blocker for the freeze/revocation path and a pre-condition for writing meaningful integration tests. **Scope:** @@ -299,7 +299,7 @@ These are the features that separate "impressive developer tool" from "enterpris --- -#### Epic 4: `expand_tilde` Consolidation and `auths-utils` Crate +~~#### Epic 4: `expand_tilde` Consolidation and `auths-utils` Crate~~ **Why it matters:** Three implementations of the same function is a maintenance hazard. The right fix is a micro-crate or a shared module that all layers can depend on without introducing circular dependencies. **Scope:** @@ -311,15 +311,16 @@ These are the features that separate "impressive developer tool" from "enterpris - The crate should be `publish = false` — it is an internal utility, not a public API surface. **Files to touch:** -- New: `crates/auths-utils/Cargo.toml`, `crates/auths-utils/src/lib.rs`, `crates/auths-utils/src/path.rs`, `crates/auths-utils/src/url.rs` +- New: `crates/auths-utils/Cargo.toml` (model after other crates), `crates/auths-utils/src/lib.rs`, `crates/auths-utils/src/path.rs`, `crates/auths-utils/src/url.rs`, `README.md` - `crates/auths-cli/src/commands/git.rs:8977` — delete `expand_tilde`, add `use auths_utils::path::expand_tilde` - `crates/auths-cli/src/commands/witness.rs:19704` — same - `crates/auths-storage/src/git/config.rs:56667` — delete `expand_tilde`, adapt error type - `Cargo.toml` (workspace) — add `auths-utils` member and workspace dependency +- `auths/scripts/releases/2_crates.py` - add `crates/auths-utils` to the correct release ordering --- -#### Epic 5: `DeviceDID` and `IdentityDID` Validation at Construction +~~#### Epic 5: `DeviceDID` and `IdentityDID` Validation at Construction~~ **Why it matters:** A DID newtype that accepts arbitrary strings provides false safety. Any malformed DID that reaches the KEL resolver or storage layer can cause confusing errors deep in the stack. **Scope:** diff --git a/docs/plans/typing_cleaning.md b/docs/plans/typing_cleaning.md index 79cb792d..0ec1c059 100644 --- a/docs/plans/typing_cleaning.md +++ b/docs/plans/typing_cleaning.md @@ -2,60 +2,98 @@ ## Context -Many cryptographic and identity fields throughout the codebase are plain `String` where they should be strongly typed newtypes. The fn-62 epic already addresses `IdentityDID` and `DeviceDID` validation. This plan covers **everything else**: signatures, commit SHAs, public keys, resource IDs, KERI prefixes/SAIDs, git refs, and policy IDs. +Many cryptographic and identity fields throughout the codebase are plain `String` where they should be strongly typed newtypes. The fn-62 epic already addresses `IdentityDID` and `DeviceDID` validation. This plan covers **everything else**: commit OIDs, public keys (hex), policy IDs, and consistent adoption of existing newtypes (`ResourceId`, `Prefix`, `Said`). + +## Design Decisions + +### Two newtype tiers (follow existing codebase convention) + +| Tier | Pattern | Serde | Constructor | Example | +|------|---------|-------|-------------|---------| +| **Unvalidated** | `From`, `Deref` | `#[serde(transparent)]` | `new()` | `ResourceId`, `PolicyId` | +| **Validated** | `TryFrom`, `AsRef` | `#[serde(try_from = "String")]` | `parse()` + `new_unchecked()` | `Capability`, `IdentityDID`, `CommitOid`, `PublicKeyHex` | + +Validated types must NOT implement `From` or `Deref` — these defeat type safety by allowing construction/coercion that bypasses validation. + +### SQL boundary (sqlite crate, not rusqlite) + +The codebase uses the `sqlite` crate (v0.32) with `BindableWithIndex`/`ReadableWithIndex` traits — NOT `rusqlite`. No `ToSql`/`FromSql` impls needed. Binding uses `stmt.bind((idx, value.as_str()))`, reading uses `stmt.read::(idx)` then wraps with `new_unchecked()` (trust the DB — data was validated on write). + +### FFI boundary (unchanged) + +Both `packages/auths-python` (PyO3) and `packages/auths-node` (napi-rs) keep `String` fields at the FFI boundary. Conversion via `.to_string()` or `.as_str().to_owned()`. No wrapper impls needed. Python type stubs and Node type definitions remain unchanged. + +### GitRef: reuse existing type + +A `GitRef` type already exists at `crates/auths-id/src/storage/layout.rs:22-71` (unvalidated, with `Deref`, `Display`, `From`, `join()`). Rather than create a competing type in auths-verifier, reuse the existing one by importing it where needed. If validation (`refs/` prefix check) is desired later, add it to the existing type. + +### Excluded from scope + +- **`IdentityEvent.previous_hash`**: This is a SHA-256 content hash of a commit OID string, NOT a commit OID itself. It stays as `String` (or gets its own `EventChainHash` type in a future epic). +- **`PairingResponse.device_x25519_pubkey`, `device_signing_pubkey`, `signature`**: These are base64url-encoded, NOT hex-encoded. They cannot be `PublicKeyHex` or `SignatureHex`. They stay as `String` (or get a `Base64UrlKey`/`Base64UrlSignature` type in a future epic). +- **KERI event fields** (`k: Vec`, `n: Vec`, `x: String`): Base64url CESR keys, tightly coupled to wire format. Defer to a future CESR typing epic. +- **`IndexedIdentity.current_keys: Vec`**: Base64url KERI keys, same encoding concern. +- **`ThresholdPolicy.signers: Vec`**: These are DID strings but mixed `IdentityDID`/`DeviceDID` — needs clarification on which DID type. Defer to fn-62 extension. + +--- ## Existing Newtypes (Already Done) These live in `crates/auths-verifier/src/` and follow established patterns: -| Type | Inner | Location | Derives | -|------|-------|----------|---------| -| `ResourceId(String)` | `String` | `core.rs:46` | `Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize` + `Deref, Display, From, From<&str>` | -| `IdentityDID(String)` | `String` | `types.rs:147` | `Debug, Clone, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord, Hash` + `Display, FromStr, Deref, AsRef, Borrow` | -| `DeviceDID(String)` | `String` | `types.rs:303` | Same as IdentityDID + Git-specific utilities | -| `Prefix(String)` | `String` | `keri.rs:66` | `Debug, Clone, Default, PartialEq, Eq, Hash, Serialize, Deserialize` + `Display, AsRef, Borrow` | -| `Said(String)` | `String` | `keri.rs:163` | Same as Prefix | -| `Ed25519PublicKey([u8; 32])` | `[u8; 32]` | `core.rs:181` | `Debug, Clone, Copy, PartialEq, Eq, Hash` + custom hex Serialize/Deserialize | -| `Ed25519Signature([u8; 64])` | `[u8; 64]` | `core.rs:283` | `Debug, Clone, PartialEq, Eq` + custom hex Serialize/Deserialize | - -**Pattern notes:** +| Type | Inner | Location | Tier | +|------|-------|----------|------| +| `ResourceId(String)` | `String` | `core.rs:46` | Unvalidated | +| `IdentityDID(String)` | `String` | `types.rs:147` | Validated | +| `DeviceDID(String)` | `String` | `types.rs:303` | Validated | +| `Prefix(String)` | `String` | `keri.rs:66` | Validated | +| `Said(String)` | `String` | `keri.rs:163` | Validated | +| `Ed25519PublicKey([u8; 32])` | `[u8; 32]` | `core.rs:181` | Validated (byte-array) | +| `Ed25519Signature([u8; 64])` | `[u8; 64]` | `core.rs:283` | Validated (byte-array) | + +**Shared conventions:** - No macros — all hand-written -- String-based types use `#[serde(transparent)]` -- Byte-array types use custom hex Serialize/Deserialize - All conditionally derive `schemars::JsonSchema` with `#[cfg_attr(feature = "schema", ...)]` -- Validated types provide `new_unchecked()` + `parse()` constructors - Error types use `thiserror::Error` +- `#[repr(transparent)]` on validated string newtypes --- ## New Newtypes to Create -### 1. `CommitOid(String)` — Git commit hash +### 1. `CommitOid(String)` — Git commit hash (Validated) + +**Where to define:** `crates/auths-verifier/src/core.rs` + +**Validation:** 40-char lowercase hex (SHA-1) or 64-char (SHA-256). Use `parse()` + `new_unchecked()`. -**Where to define:** `crates/auths-verifier/src/core.rs` (alongside `ResourceId`) +**Serde:** `#[serde(try_from = "String")]` — rejects malformed OIDs on deserialization. -**Validation:** 40-char lowercase hex string (SHA-1) or 64-char (SHA-256 for future Git) +**Traits:** `Debug, Clone, PartialEq, Eq, Hash, Serialize` + `Display`, `AsRef`, `TryFrom`, `TryFrom<&str>`, `FromStr`, `From for String` -**Derives & impls:** Same as `ResourceId` — `Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize, Deref, Display, From` +**No `Default`** — an empty `CommitOid` is semantically wrong. -**Sites to update (4):** +**git2 interop:** Cannot implement `From` in auths-verifier (no git2 dep). Use `CommitOid::new_unchecked(oid.to_string())` at call sites, following the `oid_to_event_hash` pattern at `crates/auths-id/src/witness.rs:39-62`. + +**Sites to update (3):** | File | Field/Param | Current Type | |------|-------------|-------------| | `crates/auths-index/src/index.rs:20` | `IndexedAttestation.commit_oid` | `String` | | `crates/auths-index/src/schema.rs:9` | DB column | `TEXT` (keep as TEXT, convert at boundary) | | `crates/auths-id/src/keri/cache.rs:42,247` | `CacheEntry.last_commit_oid` | `String` | -| `crates/auths-id/src/identity/events.rs:21` | `IdentityEvent.previous_hash` | `String` | -### 2. `PublicKeyHex(String)` — Hex-encoded Ed25519 public key +### 2. `PublicKeyHex(String)` — Hex-encoded Ed25519 public key (Validated) **Where to define:** `crates/auths-verifier/src/core.rs` -**Validation:** 64-char hex string (32 bytes encoded) — validate with `hex::decode` and length check +**Validation:** 64-char hex string (32 bytes) — validate with `hex::decode` and length check. + +**Serde:** `#[serde(try_from = "String")]` -**Conversion:** `pub fn to_ed25519(&self) -> Result` for parsing into the byte-array type +**Conversion:** `pub fn to_ed25519(&self) -> Result` -**Sites to update (~20):** +**Sites to update (~12, excluding base64url-encoded fields):** | File | Field/Param | Current Type | |------|-------------|-------------| @@ -67,41 +105,14 @@ These live in `crates/auths-verifier/src/` and follow established patterns: | `crates/auths-cli/src/commands/trust.rs:99` | `public_key_hex` | `String` | | `crates/auths-sdk/src/workflows/org.rs:204,240,256,273` | org admin/member keys | `String` | | `crates/auths-sdk/src/workflows/mcp.rs:16` | `root_public_key` | `String` | -| `crates/auths-mobile-ffi/src/lib.rs:82,85,351,371,442` | device key fields | `String` | -| `crates/auths-pairing-protocol/src/response.rs:18,19` | x25519/signing pubkeys | `String` | -### 3. `SignatureHex(String)` — Hex-encoded Ed25519 signature +### 3. `PolicyId(String)` — Policy identifier (Unvalidated) **Where to define:** `crates/auths-verifier/src/core.rs` -**Validation:** 128-char hex string (64 bytes encoded) - -**Conversion:** `pub fn to_ed25519(&self) -> Result` - -**Sites to update (~2):** - -| File | Field/Param | Current Type | -|------|-------------|-------------| -| `crates/auths-pairing-protocol/src/response.rs:21` | `PairingResponse.signature` | `String` | -| `crates/auths-sdk/src/workflows/artifact.rs:169` (if applicable) | signature fields | `String` | - -### 4. `GitRef(String)` — Git ref path +**Serde:** `#[serde(transparent)]` — opaque identifier, no validation needed. -**Where to define:** `crates/auths-verifier/src/core.rs` or `crates/auths-storage/src/` - -**Validation:** Must start with `refs/` — basic structural check - -**Sites to update (3):** - -| File | Field/Param | Current Type | -|------|-------------|-------------| -| `crates/auths-index/src/index.rs:18` | `IndexedAttestation.git_ref` | `String` | -| `crates/auths-policy/src/context.rs:62` | `VerificationContext.git_ref` | `Option` → `Option` | -| `crates/auths-id/src/keri/kel.rs:88` | `Kel::with_ref()` param | `String` | - -### 5. `PolicyId(String)` — Policy identifier - -**Where to define:** `crates/auths-verifier/src/core.rs` +**Traits:** Follow `ResourceId` pattern — `From`, `From<&str>`, `Deref`, `Display` **Sites to update (2):** @@ -202,15 +213,12 @@ For each conversion site, the change is mechanical: ```rust // Before (if inner field was public): did: result.did.0, -// or -did: result.did.to_string(), - -// After (identical — Display impl handles it): +// After (Display impl handles it): did: result.did.to_string(), +// Or for owned values: +did: result.did.into_inner(), ``` -If a newtype's inner field was previously accessed directly (e.g., `resource_id.0`), change to `.to_string()` or `.as_str().to_owned()`. - ### auths-mobile-ffi (Swift/Kotlin) - `crates/auths-mobile-ffi/src/lib.rs` — ~15 DID and public_key_hex fields as `String` @@ -222,44 +230,44 @@ If a newtype's inner field was previously accessed directly (e.g., `resource_id. ### Phase 1: Define New Newtypes (additive, non-breaking) -**Task: Create `CommitOid`, `PublicKeyHex`, `SignatureHex`, `GitRef`, `PolicyId`** +**Task: Create `CommitOid`, `PublicKeyHex`, `PolicyId` in auths-verifier** File: `crates/auths-verifier/src/core.rs` -Follow the `ResourceId` pattern for each: +For **validated types** (`CommitOid`, `PublicKeyHex`), follow the `Capability` pattern: +1. Define struct with `#[serde(try_from = "String")]` and `#[repr(transparent)]` +2. Derive `Debug, Clone, PartialEq, Eq, Hash, Serialize` +3. Implement `parse()` + `new_unchecked()` + `as_str()` + `into_inner()` +4. Implement `Display`, `AsRef`, `TryFrom`, `TryFrom<&str>`, `FromStr`, `From for String` +5. Define error type (e.g. `CommitOidError`, `PublicKeyHexError`) with `thiserror::Error` + +For **unvalidated types** (`PolicyId`), follow the `ResourceId` pattern: 1. Define struct with `#[serde(transparent)]` 2. Derive `Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize` -3. Implement `Deref`, `Display`, `From`, `From<&str>`, `AsRef` -4. Add `new()`, `as_str()`, `into_inner()` methods -5. For types with validation (`PublicKeyHex`, `SignatureHex`, `CommitOid`): add `parse()` returning `Result` -6. Re-export from `crates/auths-verifier/src/lib.rs` +3. Implement `Deref`, `Display`, `From`, `From<&str>` +4. Add `new()`, `as_str()` methods -Add tests in `crates/auths-verifier/tests/cases/newtypes.rs`. - -**Estimated touch: 1 file + 1 test file** +Re-export all from `crates/auths-verifier/src/lib.rs`. -### Phase 2: Adopt Existing Newtypes (`ResourceId`, `Prefix`, `Said`) +Add tests in `crates/auths-verifier/tests/cases/newtypes.rs`. -**Task: Replace `String` with existing newtypes where they should already be used** +### Phase 2: Adopt Existing Newtypes in auths-index (`ResourceId`, `Prefix`, `Said`) -This is mostly in `auths-index` and `auths-id`: -1. Add `auths-verifier` dependency to `auths-index/Cargo.toml` (if not present) -2. Replace `rid: String` with `rid: ResourceId` in `IndexedAttestation`, `IndexedOrgMember`, etc. -3. Replace `prefix: String` with `prefix: Prefix` in `IndexedIdentity`, `IndexedOrgMember` -4. Replace `tip_said: String` with `tip_said: Said` in `IndexedIdentity` -5. Update SQL binding code (rusqlite `to_sql`/`from_sql` — may need `impl ToSql for ResourceId` via `as_str()`) -6. Fix compilation in `auths-sdk` and `auths-id` for `rid`, `prefix`, `said` fields +**Prerequisite:** Add `auths-verifier` to `auths-index/Cargo.toml` dependencies (Layer 4 → Layer 1, architecturally sound). -**Estimated touch: ~8 files across auths-index, auths-id, auths-sdk** +1. Replace `rid: String` with `rid: ResourceId` in `IndexedAttestation`, `IndexedOrgMember` +2. Replace `prefix: String` with `prefix: Prefix` in `IndexedIdentity`, `IndexedOrgMember` +3. Replace `tip_said: String` with `tip_said: Said` in `IndexedIdentity` +4. Update SQL write sites: `.as_str()` on newtypes for `stmt.bind()` +5. Update SQL read sites: wrap `stmt.read::()` results with `::new_unchecked()` (trust the DB) +6. Adopt `ResourceId` in `auths-sdk` (`ArtifactSigningRequest.attestation_rid`, `SignedAttestation.rid`) and `auths-id` (`IdentityHelper.rid`) ### Phase 3: Thread `CommitOid` Through Codebase -1. Replace `commit_oid: String` with `commit_oid: CommitOid` in `IndexedAttestation`, `CacheEntry` -2. Replace `previous_hash: String` with `previous_hash: CommitOid` in `IdentityEvent` -3. Update SQL binding code -4. Update any git2 integration points (convert `git2::Oid` ↔ `CommitOid`) - -**Estimated touch: ~4 files** +1. Replace `commit_oid: String` with `commit_oid: CommitOid` in `IndexedAttestation` +2. Replace `last_commit_oid: String` with `last_commit_oid: CommitOid` in `CacheEntry` / `CachedKelState` +3. Update SQL boundary code (same pattern as Phase 2) +4. Update git2 conversion sites: `CommitOid::new_unchecked(oid.to_string())` at `auths-index/src/rebuild.rs:124`, `auths-id/src/keri/cache.rs:110`, `auths-id/src/storage/indexed.rs:94` ### Phase 4: Thread `PublicKeyHex` Through Codebase @@ -270,57 +278,43 @@ This is mostly in `auths-index` and `auths-id`: - MCP config (auths-sdk) 2. Update builder patterns in `auths-core/src/testing/builder.rs` 3. Update CLI display code +4. Exclude `PairingResponse` fields (base64url, not hex) and `auths-mobile-ffi` fields (base64url) -**Estimated touch: ~12 files** - -### Phase 5: Thread `SignatureHex`, `GitRef`, `PolicyId` - -1. `SignatureHex` in pairing protocol -2. `GitRef` in index, policy, KEL -3. `PolicyId` in threshold policy - -**Estimated touch: ~5 files** +### Phase 5: Thread `PolicyId` + DID types beyond fn-62 -### Phase 6: Thread DID Types Beyond fn-62 +1. `PolicyId` in `ThresholdPolicy` (2 sites, auths-verifier internal) +2. After fn-62 completes, extend `IdentityDID`/`DeviceDID` adoption to: + - `auths-index` — all DID fields + - `auths-id` — cache, resolve, helpers + - `auths-sdk` — workflows, types + - `auths-pairing-protocol` — response and types + - `auths-core` — witness config -After fn-62 completes, extend `IdentityDID`/`DeviceDID` adoption to: -1. `auths-index` — all DID fields -2. `auths-id` — cache, resolve, helpers -3. `auths-sdk` — workflows, types -4. `auths-pairing-protocol` — response and types -5. `auths-core` — witness config - -**Estimated touch: ~15 files** - -### Phase 7: FFI Package Updates +### Phase 6: FFI Package Updates After core types are threaded: -1. Update `packages/auths-python/src/*.rs` — change any `.0` field access to `.to_string()` or `.as_str()` +1. Update `packages/auths-python/src/*.rs` — change any `.0` field access to `.to_string()` or `.as_str().to_owned()` 2. Update `packages/auths-node/src/*.rs` — same pattern 3. Update `crates/auths-mobile-ffi/src/lib.rs` — same pattern 4. Verify Python type stubs unchanged 5. Verify Node type definitions regenerate correctly -**Estimated touch: ~10 files, all mechanical** - --- ## Summary Table -| Newtype | Define In | Sites to Update | Priority | Phase | -|---------|-----------|----------------|----------|-------| -| `CommitOid` | auths-verifier | 4 | HIGH | 1, 3 | -| `PublicKeyHex` | auths-verifier | ~20 | HIGH | 1, 4 | -| `SignatureHex` | auths-verifier | ~2 | MEDIUM | 1, 5 | -| `GitRef` | auths-verifier | 3 | MEDIUM | 1, 5 | -| `PolicyId` | auths-verifier | 2 | MEDIUM | 1, 5 | -| `ResourceId` (adopt) | already exists | 5 | HIGH | 2 | -| `Prefix` (adopt) | already exists | 2 | HIGH | 2 | -| `Said` (adopt) | already exists | 1 | HIGH | 2 | -| `IdentityDID` (extend) | fn-62 | ~15 | HIGH | 6 | -| `DeviceDID` (extend) | fn-62 | ~10 | HIGH | 6 | - -**Total: ~89 String fields across ~40 files** +| Newtype | Tier | Define In | Sites | Phase | +|---------|------|-----------|-------|-------| +| `CommitOid` | Validated | auths-verifier | 3 | 1, 3 | +| `PublicKeyHex` | Validated | auths-verifier | ~12 | 1, 4 | +| `PolicyId` | Unvalidated | auths-verifier | 2 | 1, 5 | +| `ResourceId` (adopt) | — | already exists | 5 | 2 | +| `Prefix` (adopt) | — | already exists | 2 | 2 | +| `Said` (adopt) | — | already exists | 1 | 2 | +| `IdentityDID` (extend) | — | fn-62 | ~15 | 5 | +| `DeviceDID` (extend) | — | fn-62 | ~10 | 5 | + +**Total: ~50 String fields across ~30 files** (reduced from original ~89 after excluding base64url fields, KERI event fields, and properly scoped exclusions) --- @@ -341,9 +335,19 @@ cd packages/auths-node && npm run build cd packages/auths-python && maturin develop ``` -## Risks +## Risks & Mitigations + +1. **Serde backward compatibility** — Validated types use `#[serde(try_from = "String")]` which enforces format on deserialization. Risk: old cached files (e.g., `CachedKelState`) with malformed values fail to load. Mitigation: audit existing cached data before switching; use `new_unchecked()` in cache deserialization if needed. +2. **SQL boundary** — Uses `sqlite` crate (NOT `rusqlite`). No trait impls needed. Bind via `.as_str()`, read via `String` + `new_unchecked()` wrapper. +3. **git2 interop** — Cannot implement `From` in auths-verifier (no git2 dep, orphan rule). Use `CommitOid::new_unchecked(oid.to_string())` at call sites. +4. **auths-index dependency** — Must add `auths-verifier` to `auths-index/Cargo.toml`. Architecturally sound (Layer 4 → Layer 1). +5. **WASM compilation** — All new types in auths-verifier must compile for `wasm32-unknown-unknown`. The `hex` crate is already a dependency, so validation logic is fine. + +## Deferred Items -1. **Serde backward compatibility** — Adding `#[serde(transparent)]` to new newtypes preserves JSON wire format. No breaking change for existing serialized data. -2. **SQL binding** — `rusqlite` needs `ToSql`/`FromSql` impls for newtypes used in index queries. Implement via `as_str()` delegation. -3. **git2 interop** — `CommitOid` needs conversion from `git2::Oid::to_string()`. Keep as simple `From` impl. -4. **KERI event fields** (`k: Vec`, `n: Vec`, `x: String` in `auths-id/src/keri/event.rs`) — These are base64url-encoded keys per CESR spec. Consider a `CesrKey(String)` type, but this is lower priority since KERI event structures are tightly coupled to the CESR wire format. Defer unless there's a clear validation benefit. +- `EventChainHash(String)` for `IdentityEvent.previous_hash` (SHA-256 content hash, not commit OID) +- `Base64UrlKey(String)` for `PairingResponse` X25519/signing keys +- `Base64UrlSignature(String)` for `PairingResponse.signature` +- `CesrKey(String)` for KERI event `k`/`n`/`x` fields +- `SignatureHex(String)` — no confirmed hex-encoded signature String fields after excluding base64url ones +- `GitRef` type promotion from `auths-id` to `auths-verifier` (if needed for cross-crate use) diff --git a/packages/auths-node/src/attestation_query.rs b/packages/auths-node/src/attestation_query.rs index 30f6df3d..d739a4b2 100644 --- a/packages/auths-node/src/attestation_query.rs +++ b/packages/auths-node/src/attestation_query.rs @@ -99,6 +99,6 @@ pub fn get_latest_attestation( ) })?; let group = AttestationGroup::from_list(all); - let did = DeviceDID(device_did); + let did = DeviceDID::new_unchecked(device_did); Ok(group.latest(&did).map(attestation_to_napi)) } diff --git a/packages/auths-node/src/identity.rs b/packages/auths-node/src/identity.rs index 3afc62d7..eb1f48d7 100644 --- a/packages/auths-node/src/identity.rs +++ b/packages/auths-node/src/identity.rs @@ -184,7 +184,7 @@ pub fn create_agent_identity( ) })?; - let device_did = DeviceDID(result.device_did.to_string()); + let device_did = DeviceDID::new_unchecked(result.device_did.to_string()); let attestations = attestation_storage .load_attestations_for_device(&device_did) .map_err(|e| { @@ -322,7 +322,7 @@ pub fn delegate_agent( ) })?; - let device_did = DeviceDID(result.device_did.to_string()); + let device_did = DeviceDID::new_unchecked(result.device_did.to_string()); let attestations = attestation_storage .load_attestations_for_device(&device_did) .map_err(|e| { @@ -435,7 +435,7 @@ pub fn get_identity_public_key( let keychain = get_platform_keychain_with_config(&env_config) .map_err(|e| format_error("AUTHS_KEYCHAIN_ERROR", format!("Keychain error: {e}")))?; - let did = auths_verifier::types::IdentityDID::new(&identity_did); + let did = auths_verifier::types::IdentityDID::new_unchecked(&identity_did); let aliases = keychain .list_aliases_for_identity_with_role(&did, KeyRole::Primary) .map_err(|e| format_error("AUTHS_KEY_NOT_FOUND", format!("Key lookup failed: {e}")))?; diff --git a/packages/auths-node/src/org.rs b/packages/auths-node/src/org.rs index 4ac4fa10..118fb31e 100644 --- a/packages/auths-node/src/org.rs +++ b/packages/auths-node/src/org.rs @@ -142,7 +142,7 @@ pub fn create_org( }; let signer = StorageSigner::new(keychain); - let org_did_device = DeviceDID::new(controller_did.to_string()); + let org_did_device = DeviceDID::new_unchecked(controller_did.to_string()); let attestation = create_signed_attestation( now, diff --git a/packages/auths-node/src/sign.rs b/packages/auths-node/src/sign.rs index 49fbf88d..bfeb268e 100644 --- a/packages/auths-node/src/sign.rs +++ b/packages/auths-node/src/sign.rs @@ -32,7 +32,7 @@ pub fn sign_as_identity( ) -> napi::Result { let passphrase_str = resolve_passphrase(passphrase); let (signer, provider) = make_signer(&passphrase_str, &repo_path)?; - let did = IdentityDID::new(&identity_did); + let did = IdentityDID::new_unchecked(&identity_did); let sig_bytes = signer .sign_for_identity(&did, &provider, message.as_ref()) @@ -85,7 +85,7 @@ pub fn sign_action_as_identity( let passphrase_str = resolve_passphrase(passphrase); let (signer, provider) = make_signer(&passphrase_str, &repo_path)?; - let did = IdentityDID::new(&identity_did); + let did = IdentityDID::new_unchecked(&identity_did); let sig_bytes = signer .sign_for_identity(&did, &provider, canonical.as_bytes()) diff --git a/packages/auths-node/src/verify.rs b/packages/auths-node/src/verify.rs index 92ce4464..6c7f41d8 100644 --- a/packages/auths-node/src/verify.rs +++ b/packages/auths-node/src/verify.rs @@ -129,7 +129,7 @@ pub async fn verify_device_authorization( check_batch_size(&attestations_json)?; let identity_pk_bytes = decode_pk_hex(&identity_pk_hex, "identity public key")?; let attestations = parse_attestations(&attestations_json)?; - let device = DeviceDID::new(&device_did); + let device = DeviceDID::new_unchecked(&device_did); match rust_verify_device_authorization( &identity_did, diff --git a/packages/auths-python/Cargo.lock b/packages/auths-python/Cargo.lock index 77897ee3..46d5e2d7 100644 --- a/packages/auths-python/Cargo.lock +++ b/packages/auths-python/Cargo.lock @@ -188,6 +188,7 @@ dependencies = [ "auths-core", "auths-crypto", "auths-policy", + "auths-utils", "auths-verifier", "base64", "bs58", @@ -367,6 +368,14 @@ dependencies = [ "tracing-subscriber", ] +[[package]] +name = "auths-utils" +version = "0.0.1-rc.8" +dependencies = [ + "dirs", + "thiserror 2.0.18", +] + [[package]] name = "auths-verifier" version = "0.0.1-rc.8" diff --git a/packages/auths-python/src/attestation_query.rs b/packages/auths-python/src/attestation_query.rs index 614452c5..e6d3d8a9 100644 --- a/packages/auths-python/src/attestation_query.rs +++ b/packages/auths-python/src/attestation_query.rs @@ -161,7 +161,7 @@ pub fn get_latest_attestation( )) })?; let group = AttestationGroup::from_list(all); - let did = DeviceDID(device_did.to_string()); + let did = DeviceDID::new_unchecked(device_did.to_string()); Ok(group.latest(&did).map(attestation_to_py)) }) } diff --git a/packages/auths-python/src/identity.rs b/packages/auths-python/src/identity.rs index 3e12becd..ab6e7c69 100644 --- a/packages/auths-python/src/identity.rs +++ b/packages/auths-python/src/identity.rs @@ -439,7 +439,7 @@ pub fn delegate_agent( )) })?; - let device_did = DeviceDID(result.device_did.to_string()); + let device_did = DeviceDID::new_unchecked(result.device_did.to_string()); let attestations = attestation_storage .load_attestations_for_device(&device_did) .map_err(|e| { diff --git a/packages/auths-python/src/identity_sign.rs b/packages/auths-python/src/identity_sign.rs index ed907682..a9ddeae8 100644 --- a/packages/auths-python/src/identity_sign.rs +++ b/packages/auths-python/src/identity_sign.rs @@ -58,7 +58,7 @@ pub fn sign_as_identity( passphrase: Option, ) -> PyResult { let (signer, provider) = make_signer(Some(repo_path), passphrase)?; - let did = IdentityDID::new(identity_did); + let did = IdentityDID::new_unchecked(identity_did); let msg = message.to_vec(); py.allow_threads(move || { @@ -123,7 +123,7 @@ pub fn sign_action_as_identity( })?; let (signer, provider) = make_signer(Some(repo_path), passphrase)?; - let did = IdentityDID::new(identity_did); + let did = IdentityDID::new_unchecked(identity_did); let action_type_owned = action_type.to_string(); let identity_did_owned = identity_did.to_string(); @@ -173,7 +173,7 @@ pub fn get_identity_public_key( passphrase: Option, ) -> PyResult { let (signer, provider) = make_signer(Some(repo_path), passphrase)?; - let did = IdentityDID::new(identity_did); + let did = IdentityDID::new_unchecked(identity_did); py.allow_threads(move || { let aliases = signer diff --git a/packages/auths-python/src/org.rs b/packages/auths-python/src/org.rs index 2cf0b863..56c6b80f 100644 --- a/packages/auths-python/src/org.rs +++ b/packages/auths-python/src/org.rs @@ -127,7 +127,7 @@ pub fn create_org( }; let signer = StorageSigner::new(keychain); - let org_did_device = DeviceDID::new(controller_did.to_string()); + let org_did_device = DeviceDID::new_unchecked(controller_did.to_string()); let attestation = create_signed_attestation( now, diff --git a/packages/auths-python/src/verify.rs b/packages/auths-python/src/verify.rs index 2ffe9485..565c81fb 100644 --- a/packages/auths-python/src/verify.rs +++ b/packages/auths-python/src/verify.rs @@ -178,7 +178,7 @@ pub fn verify_device_authorization( }) .collect::>>()?; - let device = DeviceDID::new(device_did); + let device = DeviceDID::new_unchecked(device_did); py.allow_threads(|| { match runtime().block_on(rust_verify_device_authorization( diff --git a/packages/auths-verifier-swift/src/lib.rs b/packages/auths-verifier-swift/src/lib.rs index 1c3fc353..643562f4 100644 --- a/packages/auths-verifier-swift/src/lib.rs +++ b/packages/auths-verifier-swift/src/lib.rs @@ -287,7 +287,7 @@ pub fn verify_device_authorization( }) .collect::, _>>()?; - let device = DeviceDID::new(&device_did); + let device = DeviceDID::new_unchecked(&device_did); // Verify match rust_verify_device_authorization(&identity_did, &device, &attestations, &identity_pk_bytes) {