Skip to content

Commit afca667

Browse files
committed
fix(se): route 15 decrypt_keypair callsites through SE-safe helpers and refactor rotation to dispatch on TypedSeed for P-256 identities
1 parent 4d9e91e commit afca667

23 files changed

Lines changed: 1745 additions & 323 deletions

File tree

crates/auths-cli/src/commands/agent/mod.rs

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -541,6 +541,17 @@ fn unlock_agent(key_alias: &str) -> Result<()> {
541541

542542
let keychain = auths_sdk::keychain::get_platform_keychain()
543543
.map_err(|e| anyhow!("Failed to get platform keychain: {}", e))?;
544+
545+
if keychain.is_hardware_backend() {
546+
return Err(anyhow!(
547+
"Agent-mode signing requires a software-backed key. Key '{}' is hardware-backed \
548+
(Secure Enclave) and cannot export raw key material needed by the SSH agent. \
549+
Use direct signing instead (which dispatches through the Secure Enclave), \
550+
or initialize a separate software-backed identity for agent use.",
551+
key_alias
552+
));
553+
}
554+
544555
let (_identity_did, _role, encrypted_data) = keychain
545556
.load_key(&auths_sdk::keychain::KeyAlias::new_unchecked(key_alias))
546557
.map_err(|e| anyhow!("Failed to load key '{}': {}", key_alias, e))?;

crates/auths-cli/src/commands/auth.rs

Lines changed: 15 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,9 @@
11
use anyhow::{Context, Result, anyhow};
22
use clap::{Parser, Subcommand};
33

4-
use auths_crypto::Pkcs8Der;
5-
use auths_sdk::crypto::decrypt_keypair;
6-
use auths_sdk::crypto::extract_seed_from_pkcs8;
7-
use auths_sdk::crypto::provider_bridge;
8-
use auths_sdk::keychain::KeyStorage;
94
use auths_sdk::storage_layout::layout;
105

116
use crate::factories::storage::build_auths_context;
12-
use auths_sdk::workflows::auth::sign_auth_challenge;
137

148
use crate::commands::executable::ExecutableCommand;
159
use crate::config::CliConfig;
@@ -76,33 +70,22 @@ fn handle_auth_challenge(nonce: &str, domain: &str, ctx: &CliConfig) -> Result<(
7670
let key_alias = auths_sdk::keychain::KeyAlias::new(&key_alias_str)
7771
.map_err(|e| anyhow!("Invalid key alias: {e}"))?;
7872

79-
let (_stored_did, _role, encrypted_key) = auths_ctx
80-
.key_storage
81-
.load_key(&key_alias)
82-
.with_context(|| format!("Failed to load key '{}'", key_alias_str))?;
83-
84-
let passphrase =
85-
passphrase_provider.get_passphrase(&format!("Enter passphrase for '{}':", key_alias))?;
86-
let pkcs8_bytes = decrypt_keypair(&encrypted_key, &passphrase)
87-
.context("Failed to decrypt key (invalid passphrase?)")?;
88-
89-
let pkcs8 = Pkcs8Der::new(&pkcs8_bytes[..]);
90-
let seed =
91-
extract_seed_from_pkcs8(&pkcs8).context("Failed to extract seed from key material")?;
92-
93-
// Derive public key from the seed instead of resolving via KEL
94-
let public_key_bytes = provider_bridge::ed25519_public_key_from_seed_sync(&seed)
95-
.context("Failed to derive public key from seed")?;
96-
let public_key_hex = hex::encode(public_key_bytes);
97-
98-
let result = sign_auth_challenge(
99-
nonce,
100-
domain,
101-
&seed,
102-
&public_key_hex,
103-
controller_did.as_str(),
73+
let message = auths_sdk::workflows::auth::build_auth_challenge_message(nonce, domain)
74+
.context("Failed to build auth challenge payload")?;
75+
76+
let (signature_bytes, public_key_bytes, _curve) = auths_sdk::keychain::sign_with_key(
77+
auths_ctx.key_storage.as_ref(),
78+
&key_alias,
79+
passphrase_provider.as_ref(),
80+
message.as_bytes(),
10481
)
105-
.context("Failed to sign auth challenge")?;
82+
.with_context(|| format!("Failed to sign auth challenge with key '{}'", key_alias))?;
83+
84+
let result = auths_sdk::workflows::auth::SignedAuthChallenge {
85+
signature_hex: hex::encode(&signature_bytes),
86+
public_key_hex: hex::encode(&public_key_bytes),
87+
did: controller_did.to_string(),
88+
};
10689

10790
if is_json_mode() {
10891
JsonResponse::success(

crates/auths-cli/src/commands/id/identity.rs

Lines changed: 11 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
use anyhow::{Context, Result, anyhow};
22
use clap::{ArgAction, Parser, Subcommand};
3-
use ring::signature::KeyPair;
43
use serde::Serialize;
54
use serde_json;
65
use std::fs;
@@ -593,23 +592,19 @@ pub fn handle_id(
593592
.load_all_attestations()
594593
.unwrap_or_default();
595594

596-
// Load the public key from keychain
595+
// Load the public key from keychain (handles SE and software keys)
597596
let keychain = get_platform_keychain()?;
598-
let (_, _role, encrypted_key) = keychain
599-
.load_key(&KeyAlias::new_unchecked(&alias))
600-
.with_context(|| format!("Key '{}' not found in keychain", alias))?;
601-
602-
// Decrypt to get public key
603-
let pass = passphrase_provider
604-
.get_passphrase(&format!("Enter passphrase for key '{}':", alias))?;
605-
let pkcs8_bytes = auths_sdk::crypto::decrypt_keypair(&encrypted_key, &pass)
606-
.context("Failed to decrypt key")?;
607-
let keypair = auths_sdk::identity::load_keypair_from_der_or_seed(&pkcs8_bytes)?;
597+
let alias_typed = KeyAlias::new_unchecked(&alias);
598+
let (public_key_bytes, _curve) = auths_sdk::keychain::extract_public_key_bytes(
599+
keychain.as_ref(),
600+
&alias_typed,
601+
passphrase_provider.as_ref(),
602+
)
603+
.with_context(|| format!("Failed to extract public key for '{}'", alias))?;
608604
#[allow(clippy::disallowed_methods)]
609-
// INVARIANT: hex::encode of Ed25519 pubkey always produces valid hex
610-
let public_key_hex = auths_verifier::PublicKeyHex::new_unchecked(hex::encode(
611-
keypair.public_key().as_ref(),
612-
));
605+
// INVARIANT: hex::encode of pubkey bytes always produces valid hex
606+
let public_key_hex =
607+
auths_verifier::PublicKeyHex::new_unchecked(hex::encode(&public_key_bytes));
613608

614609
// Create the bundle
615610
let bundle = IdentityBundle {

crates/auths-cli/src/commands/org.rs

Lines changed: 31 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
use anyhow::{Context, Result, anyhow};
22
use auths_sdk::attestation::create_signed_attestation;
33
use auths_sdk::attestation::create_signed_revocation;
4-
use auths_sdk::crypto::decrypt_keypair;
54
use auths_sdk::identity::DidResolver;
65
use auths_sdk::identity::initialize_registry_identity;
76
use chrono::{DateTime, Utc};
@@ -439,7 +438,7 @@ pub fn handle_org(
439438
serde_json::from_str(&payload_str).context("Invalid JSON in payload file")?;
440439

441440
let key_storage = get_platform_keychain()?;
442-
let (stored_did, _role, encrypted_key) = key_storage
441+
let (stored_did, _role, _encrypted_key) = key_storage
443442
.load_key(&signer_alias)
444443
.with_context(|| format!("Failed to load signer key '{}'", signer_alias))?;
445444

@@ -452,13 +451,6 @@ pub fn handle_org(
452451
));
453452
}
454453

455-
let passphrase = passphrase_provider.get_passphrase(&format!(
456-
"Enter passphrase for org identity key '{}':",
457-
signer_alias
458-
))?;
459-
let _pkcs8_bytes = decrypt_keypair(&encrypted_key, &passphrase)
460-
.context("Failed to decrypt signer key (invalid passphrase?)")?;
461-
462454
#[allow(clippy::disallowed_methods)]
463455
// INVARIANT: subject_did accepts both did:key and did:keri
464456
let subject_device_did = DeviceDID::new_unchecked(subject_did.clone());
@@ -538,17 +530,6 @@ pub fn handle_org(
538530
let controller_did = managed_identity.controller_did;
539531
let rid = managed_identity.storage_id;
540532

541-
let encrypted_key = get_platform_keychain()?
542-
.load_key(&signer_alias)
543-
.context("Failed to load signer key")?
544-
.2;
545-
let pass = passphrase_provider.get_passphrase(&format!(
546-
"Enter passphrase for identity key '{}':",
547-
signer_alias
548-
))?;
549-
let _pkcs8_bytes =
550-
decrypt_keypair(&encrypted_key, &pass).context("Failed to decrypt identity key")?;
551-
552533
#[allow(clippy::disallowed_methods)] // INVARIANT: accepts both did:key and did:keri
553534
let subject_device_did = DeviceDID::new_unchecked(subject_did.clone());
554535

@@ -965,13 +946,30 @@ pub fn handle_org(
965946
Ok(())
966947
}
967948

968-
OrgSubcommand::Join { code, registry } => handle_join(&code, &registry),
949+
OrgSubcommand::Join { code, registry } => {
950+
handle_join(&code, &registry, passphrase_provider.as_ref())
951+
}
969952
}
970953
}
971954

972955
/// Handles the `org join` subcommand by looking up and accepting an invite
973956
/// via the registry HTTP API.
974-
fn handle_join(code: &str, registry: &str) -> Result<()> {
957+
///
958+
/// Args:
959+
/// * `code`: Invite code to redeem.
960+
/// * `registry`: Base URL of the registry HTTP API.
961+
/// * `passphrase_provider`: Injected provider used to unlock the signing key
962+
/// when producing the bearer token; respects SE-backed and P-256 keys.
963+
///
964+
/// Usage:
965+
/// ```ignore
966+
/// handle_join(&code, &registry, ctx.passphrase_provider.as_ref())?;
967+
/// ```
968+
fn handle_join(
969+
code: &str,
970+
registry: &str,
971+
passphrase_provider: &dyn auths_sdk::signing::PassphraseProvider,
972+
) -> Result<()> {
975973
let rt = tokio::runtime::Runtime::new()?;
976974
let client = reqwest::Client::new();
977975
let base = registry.trim_end_matches('/');
@@ -1024,30 +1022,21 @@ fn handle_join(code: &str, registry: &str) -> Result<()> {
10241022

10251023
let key_storage = get_platform_keychain()?;
10261024
let primary_alias = KeyAlias::new_unchecked("main");
1027-
let (_stored_did, _role, encrypted_key) = key_storage
1028-
.load_key(&primary_alias)
1029-
.context("failed to load signing key — run `auths init` first")?;
1030-
1031-
let passphrase =
1032-
rpassword::prompt_password("Enter passphrase: ").context("failed to read passphrase")?;
1033-
let pkcs8_bytes = decrypt_keypair(&encrypted_key, &passphrase).context("wrong passphrase")?;
10341025

1035-
let pkcs8 = auths_crypto::Pkcs8Der::new(&pkcs8_bytes[..]);
1036-
let seed = auths_sdk::crypto::extract_seed_from_pkcs8(&pkcs8)
1037-
.context("failed to extract seed from key material")?;
1038-
1039-
// Create a signed bearer payload: { did, timestamp, signature }
10401026
#[allow(clippy::disallowed_methods)] // CLI is the presentation boundary
10411027
let timestamp = Utc::now().to_rfc3339();
10421028
let message = format!("{}\n{}", did, timestamp);
1043-
let signature = {
1044-
use ring::signature::Ed25519KeyPair;
1045-
let kp = Ed25519KeyPair::from_seed_unchecked(seed.as_bytes())
1046-
.map_err(|e| anyhow!("invalid key: {e}"))?;
1047-
let sig = kp.sign(message.as_bytes());
1048-
use base64::Engine;
1049-
base64::engine::general_purpose::STANDARD.encode(sig.as_ref())
1050-
};
1029+
1030+
let (sig_bytes, _pubkey, _curve) = auths_sdk::keychain::sign_with_key(
1031+
key_storage.as_ref(),
1032+
&primary_alias,
1033+
passphrase_provider,
1034+
message.as_bytes(),
1035+
)
1036+
.context("failed to sign invite bearer token")?;
1037+
1038+
use base64::Engine;
1039+
let signature = base64::engine::general_purpose::STANDARD.encode(&sig_bytes);
10511040

10521041
let bearer_payload = serde_json::json!({
10531042
"did": did,

crates/auths-core/src/api/ffi.rs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -582,6 +582,12 @@ pub unsafe extern "C" fn ffi_export_private_key_with_passphrase(
582582
};
583583
let alias = KeyAlias::new_unchecked(alias_str);
584584
let export_result = || -> Result<Vec<u8>, AgentError> {
585+
if keychain.is_hardware_backend() {
586+
return Err(AgentError::BackendUnavailable {
587+
backend: keychain.backend_name(),
588+
reason: "hardware-backed keys (e.g. Secure Enclave) cannot be exported via this FFI path".to_string(),
589+
});
590+
}
585591
let (_controller_did, _role, encrypted_bytes) = keychain.load_key(&alias)?;
586592
// Attempt decryption only to verify passphrase
587593
let _decrypted_pkcs8 = decrypt_keypair(&encrypted_bytes, pass_str)?;

crates/auths-core/src/api/runtime.rs

Lines changed: 18 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ use crate::crypto::provider_bridge;
1313
use crate::crypto::signer::extract_seed_from_key_bytes;
1414
use crate::crypto::signer::{decrypt_keypair, encrypt_keypair};
1515
use crate::error::AgentError;
16-
use crate::signing::PassphraseProvider;
16+
use crate::signing::{PassphraseProvider, PrefilledPassphraseProvider};
1717
use crate::storage::keychain::{KeyAlias, KeyRole, KeyStorage};
1818
use log::{debug, error, info, warn};
1919
#[cfg(target_os = "macos")]
@@ -189,6 +189,11 @@ pub fn load_keys_into_agent_with_handle(
189189
};
190190

191191
let load_result = || -> Result<Zeroizing<Vec<u8>>, AgentError> {
192+
if keychain.is_hardware_backend() {
193+
return Err(AgentError::HardwareKeyNotExportable {
194+
operation: "agent key load".to_string(),
195+
});
196+
}
192197
let (_controller_did, _role, encrypted_pkcs8) = keychain.load_key(&key_alias)?;
193198
let prompt = format!(
194199
"Enter passphrase to unlock key '{}' for agent session:",
@@ -388,6 +393,11 @@ pub fn export_key_openssh_pem(
388393
"Alias cannot be empty".to_string(),
389394
));
390395
}
396+
if keychain.is_hardware_backend() {
397+
return Err(AgentError::HardwareKeyNotExportable {
398+
operation: "OpenSSH private key export".to_string(),
399+
});
400+
}
391401
// 1. Load encrypted key data
392402
let key_alias = KeyAlias::new_unchecked(alias);
393403
let (_controller_did, _role, encrypted_pkcs8) = keychain.load_key(&key_alias)?;
@@ -452,22 +462,14 @@ pub fn export_key_openssh_pub(
452462
"Alias cannot be empty".to_string(),
453463
));
454464
}
455-
// 1. Load encrypted key data
465+
// 1. Obtain public key bytes (hardware-aware; SE returns pubkey without decryption)
456466
let key_alias = KeyAlias::new_unchecked(alias);
457-
let (_controller_did, _role, encrypted_pkcs8) = keychain.load_key(&key_alias)?;
458-
459-
// 2. Decrypt key data
460-
let pkcs8_bytes = decrypt_keypair(&encrypted_pkcs8, passphrase)?;
461-
462-
// 3. Extract seed and derive public key via CryptoProvider
463-
let (seed, pubkey_bytes, _curve) = crate::crypto::signer::load_seed_and_pubkey(&pkcs8_bytes)
464-
.map_err(|e| {
465-
AgentError::CryptoError(format!(
466-
"Failed to extract key for alias '{}': {}",
467-
alias, e
468-
))
469-
})?;
470-
let _ = seed; // seed not needed for public key export
467+
let passphrase_provider = PrefilledPassphraseProvider::new(passphrase);
468+
let (pubkey_bytes, _curve) = crate::storage::keychain::extract_public_key_bytes(
469+
keychain,
470+
&key_alias,
471+
&passphrase_provider,
472+
)?;
471473
let ssh_ed25519_pubkey =
472474
SshEd25519PublicKey::try_from(pubkey_bytes.as_slice()).map_err(|e| {
473475
AgentError::CryptoError(format!(

crates/auths-core/src/error.rs

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,16 @@ pub enum AgentError {
120120
/// HSM does not support the requested cryptographic mechanism.
121121
#[error("HSM does not support mechanism: {0}")]
122122
HsmUnsupportedMechanism(String),
123+
124+
/// Operation cannot be completed because the key is hardware-backed (SE/HSM)
125+
/// and the operation requires raw key material.
126+
#[error(
127+
"Operation '{operation}' requires a software-backed key; hardware-backed keys (e.g. Secure Enclave) cannot export raw material"
128+
)]
129+
HardwareKeyNotExportable {
130+
/// Name of the operation that requires raw key material.
131+
operation: String,
132+
},
123133
}
124134

125135
impl AuthsErrorInfo for AgentError {
@@ -149,6 +159,7 @@ impl AuthsErrorInfo for AgentError {
149159
Self::HsmDeviceRemoved => "AUTHS-E3022",
150160
Self::HsmSessionExpired => "AUTHS-E3023",
151161
Self::HsmUnsupportedMechanism(_) => "AUTHS-E3024",
162+
Self::HardwareKeyNotExportable { .. } => "AUTHS-E3025",
152163
}
153164
}
154165

@@ -206,6 +217,9 @@ impl AuthsErrorInfo for AgentError {
206217
Self::HsmUnsupportedMechanism(_) => {
207218
Some("Check that your HSM supports Ed25519 (CKM_EDDSA)")
208219
}
220+
Self::HardwareKeyNotExportable { .. } => Some(
221+
"Use a software-backed keychain backend for this operation, or re-initialize your identity without Secure Enclave",
222+
),
209223
}
210224
}
211225
}
@@ -276,3 +290,18 @@ impl From<AgentError> for ssh_agent_lib::error::AgentError {
276290
}
277291
}
278292
}
293+
294+
#[cfg(test)]
295+
mod tests {
296+
use super::*;
297+
298+
#[test]
299+
fn hardware_key_not_exportable_has_actionable_display() {
300+
let err = AgentError::HardwareKeyNotExportable {
301+
operation: "pairing".into(),
302+
};
303+
let msg = err.to_string();
304+
assert!(msg.contains("hardware"), "msg={msg}");
305+
assert!(msg.contains("pairing"), "msg={msg}");
306+
}
307+
}

0 commit comments

Comments
 (0)