Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,8 @@ jobs:
run: |
bash scripts/check-arch.sh
bash scripts/check_sdk_boundary.sh
- name: Crypto primitive inventory is current
run: python3 scripts/check_primitive_inventory.py

smoke:
name: Golden-path smoke test
Expand Down
22 changes: 4 additions & 18 deletions ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -164,16 +164,6 @@ Ports are trait interfaces that decouple domain logic from infrastructure. Produ
| `CryptoDiagnosticProvider` | `diagnostics.rs` | `auths-sdk` | `PosixDiagnosticAdapter` | `FakeCryptoDiagnosticProvider` |
| `ArtifactSource` | `artifact.rs` | `auths-cli` | `StdinArtifactSource` | inline fakes |

### auths-registry-server ports (`crates/auths-registry-server/src/ports/`)

| Trait | File | Adapter Crate | Production Adapter | Test Double |
|---|---|---|---|---|
| `HttpAdapter` | `http.rs` | `auths-infra-http` | `ReqwestHttpAdapter` | `MockHttpAdapter` (mockall) |
| `PairingStore` | `pairing_store.rs` | `auths-registry-server` | `PostgresPairingStore` | `InMemoryPairingStore` |
| `SubscriptionStore` | `subscription_store.rs` | `auths-registry-server` | `PostgresSubscriptionStore` | — |
| `TenantMetadataStore` | `tenant_metadata_store.rs` | `auths-registry-server` | `PostgresTenantMetadataStore` | — |
| `TenantResolver` | `tenant_resolver.rs` | `auths-registry-server` | `FilesystemTenantResolver` | `SingleTenantResolver` |

## Bounded Context Guide

### auths-core
Expand All @@ -200,12 +190,12 @@ Ports are trait interfaces that decouple domain logic from infrastructure. Produ
**Responsibility:** User-facing terminal commands, interactive prompts, JSON output mode.
**Must NOT:** contain business logic. All domain operations are delegated to auths-sdk workflows.

### auths-registry-server
**Responsibility:** HTTP API for KERI identity registration, KEL management, platform claims, multi-tenant routing.
### auths-api
**Responsibility:** Thin HTTP presentation layer over the SDK — currently a minimal skeleton (health check + agent-passport control-plane handlers); domain routes are (re)mounted over SDK workflows as an HTTP surface is needed. Single-org self-host, no multi-tenant control plane.
**Must NOT:** contain domain logic beyond input validation and error translation to HTTP status codes.

### auths-auth-server
**Responsibility:** "Login with Auths" challenge-response authentication. Issues challenges, verifies signatures by resolving identity keys.
### auths-rp
**Responsibility:** Relying-party wire boundary — turns an `Authorization: Auths-Presentation` header into a parsed `PresentationEnvelope` for verification (authenticate a request by proof-of-control of a delegated credential, not a bearer key).
**Must NOT:** store private keys or implement key derivation logic.

### auths-infra-http
Expand All @@ -216,10 +206,6 @@ Ports are trait interfaces that decouple domain logic from infrastructure. Produ
**Responsibility:** Production git2-backed implementations of the storage ports defined in auths-core (`GitEventLogWriter`, `GitEventLogReader`, `GitBlobReader`, `GitBlobWriter`, `GitRefReader`, `GitRefWriter`) and git introspection ports from auths-sdk (`SystemGitLogProvider`, `SystemGitConfigProvider`).
**Must NOT:** contain business logic or be referenced by core/domain crates.

### auths-cache
**Responsibility:** Redis-backed tiered identity resolver with write-through archival to Git.
**Must NOT:** be referenced by core or domain crates.

### auths-index
**Responsibility:** SQLite-backed O(1) attestation index for fast lookups by device DID.
**Must NOT:** be referenced by core or domain crates.
Expand Down
3 changes: 3 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 3 additions & 0 deletions crates/auths-cli/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,9 @@ witness-node = ["dep:auths-witness-node"]
[target.'cfg(unix)'.dependencies]
nix = { version = "0.29", features = ["signal", "process"] }

[target.'cfg(windows)'.dependencies]
windows = { version = "0.58", features = ["Win32_System_Threading", "Win32_Foundation"] }

[dev-dependencies]
auths-crypto = { path = "../auths-crypto", features = ["test-utils"] }
auths-verifier = { path = "../auths-verifier", features = ["test-utils"] }
Expand Down
25 changes: 23 additions & 2 deletions crates/auths-cli/src/bin/sign.rs
Original file line number Diff line number Diff line change
Expand Up @@ -297,8 +297,29 @@ fn run_sign(args: &Args) -> Result<()> {

#[allow(clippy::disallowed_methods)]
let now = chrono::Utc::now();
let signature_pem =
CommitSigningWorkflow::execute(&ctx, params, now).map_err(anyhow::Error::new)?;

// Signing can block on a hardware biometric prompt or a passphrase entry.
// If it stalls past a couple of seconds, tell the user why instead of
// leaving the terminal frozen with no explanation. The notice goes to
// stderr only — stdout carries the gpg.ssh.program protocol. The channel
// wakes the notice thread the instant signing finishes, so a fast sign
// adds no latency.
let (done_tx, done_rx) = std::sync::mpsc::channel::<()>();
let notice = std::thread::spawn(move || {
if done_rx
.recv_timeout(std::time::Duration::from_secs(2))
.is_err()
{
eprintln!(
"auths-sign: waiting for signing approval — touch your security key or enter your passphrase…"
);
}
});
let signing_result =
CommitSigningWorkflow::execute(&ctx, params, now).map_err(anyhow::Error::new);
let _ = done_tx.send(());
let _ = notice.join();
let signature_pem = signing_result?;

let sig_path = format!("{}.sig", buffer_file.display());
fs::write(&sig_path, &signature_pem)
Expand Down
26 changes: 25 additions & 1 deletion crates/auths-cli/src/commands/status.rs
Original file line number Diff line number Diff line change
Expand Up @@ -571,7 +571,31 @@ fn is_process_running(pid: u32) -> bool {
signal::kill(Pid::from_raw(pid as i32), None).is_ok()
}

#[cfg(not(unix))]
/// Windows: a PID is running if we can open it and `GetExitCodeProcess`
/// reports `STILL_ACTIVE` (259). `OpenProcess` failing (no such PID, or access
/// denied) is treated as not-running, matching the Unix `kill(pid, 0)` check.
#[cfg(windows)]
fn is_process_running(pid: u32) -> bool {
use windows::Win32::Foundation::{CloseHandle, FALSE};
use windows::Win32::System::Threading::{
GetExitCodeProcess, OpenProcess, PROCESS_QUERY_LIMITED_INFORMATION,
};
const STILL_ACTIVE: u32 = 259;
// SAFETY: standard Win32 open/query/close of a process handle; the handle is
// always closed, and all pointers point to live stack locals.
unsafe {
let handle = match OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, FALSE, pid) {
Ok(h) => h,
Err(_) => return false,
};
let mut code: u32 = 0;
let running = GetExitCodeProcess(handle, &mut code).is_ok() && code == STILL_ACTIVE;
let _ = CloseHandle(handle);
running
}
}

#[cfg(not(any(unix, windows)))]
fn is_process_running(_pid: u32) -> bool {
false
}
Expand Down
2 changes: 1 addition & 1 deletion crates/auths-crypto/src/provider.rs
Original file line number Diff line number Diff line change
Expand Up @@ -528,7 +528,7 @@ compile_error!(
/// Selection is compile-time:
/// - default build → [`crate::ring_provider::RingCryptoProvider`]
/// - `--features fips` → [`crate::aws_lc_provider::AwsLcProvider`] (AWS-LC-FIPS)
/// - `--features cnsa` → (fn-128.T4; TODO: returns Ring until CnsaProvider lands)
/// - `--features cnsa` → [`crate::cnsa_provider::CnsaProvider`] (P-384 / AES-256-GCM / SHA-384; rejects P-256/ChaCha/SHA-256)
///
/// Domain code SHOULD route cryptographic operations through this function
/// rather than constructing `p256::ecdsa::SigningKey` (or equivalent) directly —
Expand Down
7 changes: 7 additions & 0 deletions crates/auths-id/src/keri/anchor.rs
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,13 @@ fn canonicalize_and_said<T: serde::Serialize>(data: &T) -> Result<Said, AnchorEr
compute_said(&value).map_err(|e| AnchorError::Serialization(e.to_string()))
}

/// The anchoring SAID for an attestation (canonical JSON → SAID) — the same
/// digest [`try_stage_anchor`] seals. Exposed so bulk flows that carry many
/// attestation seals in one shared `ixn` seal the identical value.
pub fn attestation_said<T: serde::Serialize>(attestation: &T) -> Result<Said, AnchorError> {
canonicalize_and_said(attestation)
}

fn sign_ixn(
ixn: &IxnEvent,
signer: &dyn SecureSigner,
Expand Down
135 changes: 135 additions & 0 deletions crates/auths-id/src/keri/delegation.rs
Original file line number Diff line number Diff line change
Expand Up @@ -386,6 +386,141 @@ pub fn author_root_anchor_ixn(
Ok(ixn)
}

/// One agent to incept in a bulk delegation batch (issue #255 bulk onboarding).
pub struct BulkAgentSpec {
/// Keychain alias to store the new agent key under.
pub device_alias: KeyAlias,
/// Curve for the new agent key.
pub device_curve: CurveType,
}

/// Result of [`incept_delegated_agents_bulk`]: the devices plus the one anchoring `ixn`.
pub struct BulkDelegation {
/// The delegated identifiers, in spec order.
pub devices: Vec<DelegatedDevice>,
/// The single root `ixn` anchoring every dip in the batch.
pub anchor_ixn: IxnEvent,
}

/// Incept a batch of agents as delegated identifiers with ONE root anchoring `ixn`
/// and ONE atomic commit — the bulk-onboarding write path (issue #255 / PRD KL-9).
///
/// The per-agent path spreads three anchors over three root events (the dip
/// `Seal::KeyEvent`, the `agent:{prefix}` role marker, the attestation digest) and
/// three commits; here the same three seals per agent ride in one shared `ixn`, and
/// every dip's `-G` source seal points at that one anchoring event — the same
/// whole-set-at-one-KEL-position semantics [`revoke_delegated_devices_batch`]
/// already uses on the way out.
///
/// `extras` runs once per agent AFTER its keys are stored in the keychain: it may
/// stage additional writes into the shared batch (e.g. the delegation-attestation
/// blob) and returns extra seals to carry in the anchoring `ixn` (e.g. the
/// attestation digest). Witness receipting is the caller's concern — one receipt
/// round per batch instead of per agent; this function does not contact witnesses.
#[allow(clippy::too_many_arguments)]
pub fn incept_delegated_agents_bulk(
backend: &(dyn RegistryBackend + Send + Sync),
root_prefix: &Prefix,
root_alias: &KeyAlias,
root_curve: CurveType,
specs: &[BulkAgentSpec],
passphrase_provider: &dyn PassphraseProvider,
keychain: &(dyn KeyStorage + Send + Sync),
mut extras: impl FnMut(
&DeviceDipBundle,
&mut crate::storage::registry::backend::AtomicWriteBatch,
) -> Result<Vec<Seal>, InitError>,
) -> Result<BulkDelegation, InitError> {
let mut batch = crate::storage::registry::backend::AtomicWriteBatch::new();
let mut bundles = Vec::with_capacity(specs.len());
let mut seals = Vec::with_capacity(specs.len() * 3);

for spec in specs {
let bundle = build_device_dip(root_prefix, spec.device_curve)?;

// Store the agent's keys first so `extras` (attestation building) can read
// the public key back through the keychain exactly like the per-agent path.
let pass = passphrase_provider.get_passphrase(&format!(
"Create passphrase for device key '{}':",
spec.device_alias
))?;
let enc_cur = encrypt_keypair(bundle.current_pkcs8.as_ref(), &pass)?;
keychain.store_key(
&spec.device_alias,
&bundle.device_did,
KeyRole::Primary,
&enc_cur,
)?;
let next_alias = KeyAlias::new_unchecked(format!("{}--next-0", spec.device_alias));
let enc_next = encrypt_keypair(bundle.next_pkcs8.as_ref(), &pass)?;
keychain.store_key(
&next_alias,
&bundle.device_did,
KeyRole::NextRotation,
&enc_next,
)?;

seals.push(Seal::KeyEvent {
i: bundle.device_prefix.clone(),
s: KeriSequence::new(0),
d: bundle.dip.d.clone(),
});
seals.push(Seal::Digest {
d: Said::new_unchecked(agent_role_marker(&bundle.device_prefix)),
});
seals.extend(extras(&bundle, &mut batch)?);

bundles.push(bundle);
}

// One anchoring ixn for the whole batch, staged BEFORE the dips so the dips
// staged after it see the anchor through the batch's state/tip overlays.
let anchor_ixn = stage_root_anchor_ixn(
backend,
root_prefix,
root_alias,
root_curve,
seals,
passphrase_provider,
keychain,
&mut batch,
)?;

let source_seal = SourceSeal {
s: anchor_ixn.s,
d: anchor_ixn.d.clone(),
};
let mut devices = Vec::with_capacity(bundles.len());
for (spec, bundle) in specs.iter().zip(bundles) {
let mut anchored_dip = bundle.dip;
anchored_dip.source_seal = Some(source_seal.clone());
let mut attachment = bundle.attachment;
attachment.extend_from_slice(
&serialize_source_seal_couples(&[source_seal.clone()])
.map_err(|e| InitError::Keri(format!("source seal serialization: {e}")))?,
);
batch.stage_event(
bundle.device_prefix.clone(),
Event::Dip(anchored_dip),
attachment,
);
devices.push(DelegatedDevice {
device_did: bundle.device_did,
device_prefix: bundle.device_prefix,
device_alias: spec.device_alias.clone(),
});
}

backend
.commit_batch(&batch)
.map_err(|e| InitError::Registry(e.to_string()))?;

Ok(BulkDelegation {
devices,
anchor_ixn,
})
}

/// The role a delegated identifier plays.
///
/// Agents and devices share the exact same `dip`/`drt` delegation mechanism; the
Expand Down
1 change: 1 addition & 0 deletions crates/auths-infra-http/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ async-trait = "0.1"
auths-core = { workspace = true }
auths-crypto = { workspace = true }
auths-oidc-port = { path = "../auths-oidc-port", version = "0.1.3" }
auths-pairing-protocol = { workspace = true }
auths-verifier = { workspace = true, features = ["native"] }
futures-util = "0.3"
# rust_crypto: the validation backend. Declared HERE because this crate is the
Expand Down
27 changes: 19 additions & 8 deletions crates/auths-infra-http/src/github_ssh_keys.rs
Original file line number Diff line number Diff line change
Expand Up @@ -194,16 +194,27 @@ async fn post_ssh_key_with_retry(

let status = resp.status().as_u16();

// Success: key created
// Success: key created. Return the real key id — never a placeholder,
// since callers store it as the key's metadata handle.
if status == 201 {
match resp.json::<SshKeyResponse>().await {
Ok(key) => return Ok(key.id.to_string()),
Err(_e) => {
// If deserialization fails but we got 201, the key was created.
// Return a placeholder - metadata storage will verify it worked.
return Ok("created".to_string());
}
let body = resp.text().await.unwrap_or_default();
// Primary: typed parse.
if let Ok(key) = serde_json::from_str::<SshKeyResponse>(&body) {
return Ok(key.id.to_string());
}
// Fallback: response shape changed but the id is still present — pull it directly.
if let Some(id) = serde_json::from_str::<serde_json::Value>(&body)
.ok()
.and_then(|v| v.get("id").and_then(serde_json::Value::as_u64))
{
return Ok(id.to_string());
}
// 201 but no extractable id: surface it, don't fabricate a handle.
return Err(PlatformError::Platform {
message: format!(
"GitHub returned 201 Created but the SSH-key id could not be parsed: {body}"
),
});
}

// 422: Unprocessable Entity - likely duplicate, treat as success
Expand Down
27 changes: 21 additions & 6 deletions crates/auths-infra-http/src/pairing_client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -117,14 +117,29 @@ impl PairingRelayClient for HttpPairingRelayClient {
registry_url: &str,
code: &str,
) -> impl Future<Output = Result<GetSessionResponse, NetworkError>> + Send {
use auths_pairing_protocol::lookup_auth::{LOOKUP_PATH, build_lookup_authorization};
let guard = guard_registry_url(registry_url).map_err(map_ssrf_error);
let url = format!(
"{}/v1/pairing/sessions/by-code/{}",
registry_url.trim_end_matches('/'),
code
);
let url = format!("{}{}", registry_url.trim_end_matches('/'), LOOKUP_PATH);
let endpoint = registry_url.to_string();
let req = self.client.get(&url);

// Prove knowledge of the short code without sending it in the clear: HMAC
// a canonical GET with a key derived from the code. The timestamp and a
// fresh random nonce bind the request against replay.
#[allow(clippy::disallowed_methods)] // wire boundary: no clock is injected into the relay client
let ts = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs() as i64)
.unwrap_or(0);
let mut nonce = [0u8; 16];
{
use rand::RngCore;
let mut rng = rand::rngs::OsRng;
rng.fill_bytes(&mut nonce);
}
let req = self
.client
.get(&url)
.header("Authorization", build_lookup_authorization(code, ts, &nonce));

async move {
guard?;
Expand Down
Loading
Loading