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
29 changes: 29 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,35 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Curve-agnostic refactor Round 2 (fn-116) + fn-115 follow-up

- **Witness server** (`crates/auths-core/src/witness/server.rs`) — P-256 witnesses now supported end-to-end. `WitnessServerInner.{seed, public_key}` replaced with `signer: TypedSignerKey` (curve-tagged); `WitnessServerConfig::with_generated_keypair(db_path, curve: CurveType)` accepts curve parameter; DID derivation uses `auths_crypto::{ed25519,p256}_pubkey_to_did_key` per curve (no more `z6Mk` hardcode). CLI layer defaults to P-256 witnesses.
- **KERI event-submission validator** now parses `k[0]` via `KeriPublicKey::parse` (CESR-aware); dispatches signature verify on parsed curve. Hex-encoded `k[0]` retained as legacy back-compat branch for Ed25519-only.
- **CESR strict parser** — `KeriPublicKey::parse` rejects legacy `1AAJ` P-256 verkey prefix (CESR spec's `1AAJ` is the P-256 *signature* code, not a verkey code). `1AAI` is the only accepted P-256 verkey prefix. Pre-launch posture — no on-disk v1 identities to protect.
- **Typed newtypes** for non-signing 32-byte fields:
- `auths_crypto::Hash256` (re-exported via `auths_verifier::Hash256`) for content digests. `ApprovalAttestation.request_hash` migrated.
- `auths_pairing_protocol::X25519PublicKey` for X25519 ECDH keys. `CompletedPairing.initiator_x25519_pub` migrated.
- Both use `#[serde(transparent)]` — byte-identical wire format.
- **`SeedSignerKey`** now holds `DevicePublicKey` + `curve: CurveType` instead of `[u8; 32]`. Sign path dispatches via `TypedSignerKey::sign` (curve-aware).
- **`RotationSigner` type alias deleted** — all workspace callers migrated to `TypedSignerKey`.
- **`did_key_to_ed25519` and `ed25519_to_did_key` wrappers deleted** from `auths_id::identity::resolve`. Callers use `auths_crypto::did_key_decode` + `DecodedDidKey` variants. Deny-list entry removed from all 7 `clippy.toml` files.
- **`ED25519_PUBLIC_KEY_LEN`** no longer used outside `auths_crypto` — `wasm.rs` migrated to `CurveType::from_public_key_len` for curve-aware length validation.
- **Doc comment sweep** — `auths-core/src/ports/{network,transparency_log}` and `packages/auths-python` docstrings updated to not claim Ed25519-specificity in curve-agnostic functions.
- **Pairing-protocol test helper** — `generate_ed25519_keypair_sync` no longer byte-slices ring PKCS8 internals; routes through `auths_crypto::parse_key_material` (curve-detecting).

### SSH P-256 wire format (fn-117)

- **RFC 5656 `ecdsa-sha2-nistp256` SSH support landed.** Agent-mode signing, `add_identity`, `request_identities`, `sign`, `remove_identity`, OpenSSH PEM export (`export_key_openssh_pem`), and `.pub` line export (`export_key_openssh_pub`) all curve-dispatch. `SeedSignerKey::kind()` reports the correct `SshAlgorithm::Ecdsa { curve: NistP256 }` for P-256 seeds; `SeedSignerKey::sign()` produces DER-encoded `(r, s)` signatures via `typed_sign`.
- **`AgentError::P256SshUnsupported` variant deleted** (was error code `AUTHS-E3026`, introduced in fn-116.18 as a loud-fail placeholder). P-256 identities now work with the SSH agent flow with no caller-visible errors.
- **`AgentCore` stores curve alongside seed** (`StoredKey { seed, curve }`) so the sign path dispatches on the curve of the key that was registered — no more inference from public-key length.
- **macOS system agent registration** (`register_keys_with_macos_agent_with_handle`) propagates curve through to PEM conversion; both Ed25519 and P-256 keys can be `ssh-add`ed via the platform agent.

### Deferred follow-up (tracked in `.flow/fn-114-dirty-crates.txt`)

- `TypedSignature` enum graduation (variant-per-curve) — deferred. Current newtype covers the 64-byte coincidence (Ed25519 = P-256 r||s). Full enum becomes load-bearing when a curve with a different signature length arrives (ML-DSA-44 = 2420 bytes).
- B3 typed-wrapper sweep (`Ed25519PublicKey` / `Ed25519Signature` struct fields across ~20 production files) — deferred alongside the enum graduation.
- Per-site migration of remaining banned-API call sites across production crates. Workspace clippy green via the crate-level transitional allows that fn-115 was scoped to remove.

### Removed

- **xtask:** Removed `cargo xt ci-setup`. Use `auths ci setup` (or `just ci-setup`) instead.
Expand Down
1 change: 0 additions & 1 deletion clippy.toml
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,6 @@ disallowed-methods = [
{ path = "auths_core::crypto::provider_bridge::sign_ed25519_sync", reason = "use auths_crypto::sign(&TypedSeed, msg).", allow-invalid = true },
{ path = "auths_core::crypto::provider_bridge::ed25519_public_key_from_seed_sync", reason = "use auths_crypto::public_key(&TypedSeed) or TypedSignerKey::public_key().", allow-invalid = true },
{ path = "auths_crypto::did_key_to_ed25519", reason = "use did_key_decode — returns DecodedDidKey.", allow-invalid = true },
{ path = "auths_id::identity::resolve::ed25519_to_did_key", reason = "use did_key_decode + DecodedDidKey::P256/Ed25519.", allow-invalid = true },
]
allow-unwrap-in-tests = true
allow-expect-in-tests = true
1 change: 0 additions & 1 deletion crates/auths-cli/clippy.toml
Original file line number Diff line number Diff line change
Expand Up @@ -30,5 +30,4 @@ disallowed-methods = [
{ path = "auths_core::crypto::provider_bridge::sign_ed25519_sync", reason = "use auths_crypto::sign(&TypedSeed, msg).", allow-invalid = true },
{ path = "auths_core::crypto::provider_bridge::ed25519_public_key_from_seed_sync", reason = "use auths_crypto::public_key(&TypedSeed) or TypedSignerKey::public_key().", allow-invalid = true },
{ path = "auths_crypto::did_key_to_ed25519", reason = "use did_key_decode — returns DecodedDidKey.", allow-invalid = true },
{ path = "auths_id::identity::resolve::ed25519_to_did_key", reason = "use did_key_decode + DecodedDidKey::P256/Ed25519.", allow-invalid = true },
]
36 changes: 18 additions & 18 deletions crates/auths-cli/src/commands/witness.rs
Original file line number Diff line number Diff line change
Expand Up @@ -67,26 +67,26 @@ pub fn handle_witness(cmd: WitnessCommand, repo_opt: Option<PathBuf>) -> Result<
let rt = tokio::runtime::Runtime::new()?;
rt.block_on(async {
let state = {
let (seed, pubkey) =
auths_sdk::crypto::provider_bridge::generate_ed25519_keypair_sync()
.map_err(|e| anyhow::anyhow!("Failed to generate keypair: {}", e))?;

let witness_did = if let Some(did) = witness_did {
did
// fn-116.1/B1a: curve-aware keypair generation. Default to P-256
// at the CLI layer (workspace default); plumb --curve through
// the subcommand if explicit selection becomes necessary.
let curve = auths_crypto::CurveType::P256;
let cfg = WitnessServerConfig::with_generated_keypair(db_path, curve)
.map_err(|e| anyhow::anyhow!("Failed to generate witness keypair: {e}"))?;
let cfg = if let Some(did_override) = witness_did {
WitnessServerConfig {
#[allow(clippy::disallowed_methods)]
// INVARIANT: caller-supplied witness DID
witness_did: auths_verifier::types::DeviceDID::new_unchecked(
did_override,
),
..cfg
}
} else {
format!("did:key:z6Mk{}", hex::encode(&pubkey[..16]))
cfg
};

WitnessServerState::new(WitnessServerConfig {
#[allow(clippy::disallowed_methods)] // INVARIANT: witness_did derived from keypair
witness_did: auths_verifier::types::DeviceDID::new_unchecked(witness_did),
keypair_seed: seed,
keypair_pubkey: pubkey,
db_path,
tls_cert_path: None,
tls_key_path: None,
})
.map_err(|e| anyhow::anyhow!("Failed to create witness state: {}", e))?
WitnessServerState::new(cfg)
.map_err(|e| anyhow::anyhow!("Failed to create witness state: {}", e))?
};

println!(
Expand Down
1 change: 0 additions & 1 deletion crates/auths-core/clippy.toml
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,6 @@ disallowed-methods = [
{ path = "auths_core::crypto::provider_bridge::sign_ed25519_sync", reason = "use auths_crypto::sign(&TypedSeed, msg).", allow-invalid = true },
{ path = "auths_core::crypto::provider_bridge::ed25519_public_key_from_seed_sync", reason = "use auths_crypto::public_key(&TypedSeed) or TypedSignerKey::public_key().", allow-invalid = true },
{ path = "auths_crypto::did_key_to_ed25519", reason = "use did_key_decode — returns DecodedDidKey.", allow-invalid = true },
{ path = "auths_id::identity::resolve::ed25519_to_did_key", reason = "use did_key_decode + DecodedDidKey::P256/Ed25519.", allow-invalid = true },
]

disallowed-types = [
Expand Down
154 changes: 82 additions & 72 deletions crates/auths-core/src/agent/client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -135,16 +135,22 @@ pub fn agent_sign<P: AsRef<Path>>(
.map_err(AgentError::IO)?;

// Build the sign request — detect key type from length
// TODO: accept CurveType parameter once agent core carries curve info
let key_data = if pubkey.len() == 32 {
#[allow(clippy::unwrap_used)] // INVARIANT: length checked
let pubkey_array: [u8; 32] = pubkey.try_into().unwrap();
KeyData::Ed25519(Ed25519PublicKey(pubkey_array))
} else {
return Err(AgentError::InvalidInput(format!(
"Unsupported public key length for agent signing: {}",
pubkey.len()
)));
let key_data = match pubkey.len() {
32 => {
#[allow(clippy::unwrap_used)] // INVARIANT: length checked
let pubkey_array: [u8; 32] = pubkey.try_into().unwrap();
KeyData::Ed25519(Ed25519PublicKey(pubkey_array))
}
33 | 65 => {
let ecdsa_pk = ssh_key::public::EcdsaPublicKey::from_sec1_bytes(pubkey)
.map_err(|e| AgentError::InvalidInput(format!("Invalid P-256 public key: {e}")))?;
KeyData::Ecdsa(ecdsa_pk)
}
n => {
return Err(AgentError::InvalidInput(format!(
"Unsupported public key length for agent signing: {n}"
)));
}
};

// Encode the sign request using the wire protocol
Expand Down Expand Up @@ -174,10 +180,10 @@ pub fn add_identity<P: AsRef<Path>>(

debug!("Adding identity to agent at {:?}", socket_path);

// Parse the PKCS#8 bytes to extract the seed
let seed = extract_ed25519_seed(pkcs8_bytes)?;
// Parse the PKCS#8 to detect curve + extract seed+public
let parsed = auths_crypto::parse_key_material(pkcs8_bytes)
.map_err(|e| AgentError::KeyDeserializationError(e.to_string()))?;

// Connect to the agent
let mut stream = UnixStream::connect(socket_path).map_err(|e| {
error!("Failed to connect to agent: {}", e);
AgentError::IO(e)
Expand All @@ -190,19 +196,35 @@ pub fn add_identity<P: AsRef<Path>>(
.set_write_timeout(Some(Duration::from_secs(30)))
.map_err(AgentError::IO)?;

// Create SSH key from seed
let ssh_keypair = SshEd25519Keypair::from_seed(&seed);
let pubkey_bytes = ssh_keypair.public.0.to_vec();
let keypair_data = KeypairData::Ed25519(ssh_keypair);
let (keypair_data, pubkey_bytes) = match parsed.seed.curve() {
auths_crypto::CurveType::Ed25519 => {
let ssh_keypair = SshEd25519Keypair::from_seed(parsed.seed.as_bytes());
let pubkey = ssh_keypair.public.0.to_vec();
(KeypairData::Ed25519(ssh_keypair), pubkey)
}
auths_crypto::CurveType::P256 => {
use p256::elliptic_curve::sec1::ToEncodedPoint;
use ssh_key::private::{EcdsaKeypair, EcdsaPrivateKey};

let secret = p256::SecretKey::from_slice(parsed.seed.as_bytes())
.map_err(|e| AgentError::CryptoError(format!("P-256 secret key parse: {e}")))?;
let public = secret.public_key();
let keypair = EcdsaKeypair::NistP256 {
public: public.to_encoded_point(false),
private: EcdsaPrivateKey::from(secret),
};
(KeypairData::Ecdsa(keypair), parsed.public_key.clone())
}
};

let private_key = SshPrivateKey::new(keypair_data, "auths-key")
.map_err(|e| AgentError::CryptoError(format!("Failed to create SSH key: {}", e)))?;

// Send add identity request
add_identity_raw(&mut stream, &private_key)?;

info!(
"Successfully added identity to agent: {:?}...",
hex::encode(&pubkey_bytes[..4])
hex::encode(&pubkey_bytes[..4.min(pubkey_bytes.len())])
);
Ok(pubkey_bytes)
}
Expand Down Expand Up @@ -236,6 +258,7 @@ pub fn list_identities<P: AsRef<Path>>(socket_path: P) -> Result<Vec<Vec<u8>>, A
.into_iter()
.filter_map(|id| match id.pubkey {
KeyData::Ed25519(pk) => Some(pk.0.to_vec()),
KeyData::Ecdsa(pk) => Some(pk.as_ref().to_vec()),
_ => None,
})
.collect();
Expand Down Expand Up @@ -295,37 +318,6 @@ pub fn remove_all_identities<P: AsRef<Path>>(socket_path: P) -> Result<(), Agent

// --- Internal protocol helpers ---

/// Extract Ed25519 seed from PKCS#8 bytes.
fn extract_ed25519_seed(pkcs8_bytes: &[u8]) -> Result<[u8; 32], AgentError> {
use pkcs8::PrivateKeyInfo;
use pkcs8::der::Decode;

// Try to parse as PKCS#8
let pk_info = PrivateKeyInfo::from_der(pkcs8_bytes).map_err(|e| {
AgentError::KeyDeserializationError(format!("Failed to parse PKCS#8: {}", e))
})?;

let seed = pk_info.private_key;
if seed.len() == 32 {
let mut arr = [0u8; 32];
arr.copy_from_slice(seed);
return Ok(arr);
}

// For ring's PKCS#8 format, the seed might be wrapped differently
// Try to extract from the raw bytes
if pkcs8_bytes.len() >= 48 {
let mut arr = [0u8; 32];
arr.copy_from_slice(&pkcs8_bytes[16..48]);
return Ok(arr);
}

Err(AgentError::KeyDeserializationError(format!(
"Could not extract Ed25519 seed (got {} bytes)",
seed.len()
)))
}

/// Send SSH_AGENTC_REQUEST_IDENTITIES and parse response.
fn request_identities_raw(stream: &mut UnixStream) -> Result<Vec<Identity>, AgentError> {
// Send request: length (4 bytes) + message type (1 byte)
Expand Down Expand Up @@ -532,22 +524,16 @@ fn parse_sign_response(data: &[u8]) -> Result<Vec<u8>, AgentError> {
/// Encode a public key as an SSH blob.
fn encode_pubkey_blob(pubkey: &KeyData) -> Result<Vec<u8>, AgentError> {
match pubkey {
KeyData::Ed25519(pk) => {
let mut blob = Vec::new();

// Key type string
let key_type = b"ssh-ed25519";
blob.extend_from_slice(&(key_type.len() as u32).to_be_bytes());
blob.extend_from_slice(key_type);

// Public key bytes
blob.extend_from_slice(&32u32.to_be_bytes());
blob.extend_from_slice(&pk.0);

Ok(blob)
}
KeyData::Ed25519(pk) => Ok(crate::crypto::ssh::encode_ssh_pubkey(
&pk.0,
auths_crypto::CurveType::Ed25519,
)),
KeyData::Ecdsa(pk) => Ok(crate::crypto::ssh::encode_ssh_pubkey(
pk.as_ref(),
auths_crypto::CurveType::P256,
)),
_ => Err(AgentError::InvalidInput(
"Only Ed25519 keys are supported".to_string(),
"Only Ed25519 and NistP256 keys are supported".to_string(),
)),
}
}
Expand All @@ -573,21 +559,47 @@ fn add_identity_raw(
msg.extend_from_slice(&kp.public.0);

// Private key (64 bytes = seed + public, length-prefixed)
// Ed25519 private key in SSH format is seed || public
let mut priv_bytes = Vec::with_capacity(64);
priv_bytes.extend_from_slice(&kp.private.to_bytes());
priv_bytes.extend_from_slice(&kp.public.0);
msg.extend_from_slice(&(priv_bytes.len() as u32).to_be_bytes());
msg.extend_from_slice(&priv_bytes);

// Comment (empty)
// Comment
let comment = b"auths-key";
msg.extend_from_slice(&(comment.len() as u32).to_be_bytes());
msg.extend_from_slice(comment);
}
KeypairData::Ecdsa(ssh_key::private::EcdsaKeypair::NistP256 { public, private }) => {
// Key type string
let key_type = b"ecdsa-sha2-nistp256";
msg.extend_from_slice(&(key_type.len() as u32).to_be_bytes());
msg.extend_from_slice(key_type);

// Curve name
let curve_name = b"nistp256";
msg.extend_from_slice(&(curve_name.len() as u32).to_be_bytes());
msg.extend_from_slice(curve_name);

// Public key (SEC1 uncompressed encoding)
let public_bytes = public.as_bytes();
msg.extend_from_slice(&(public_bytes.len() as u32).to_be_bytes());
msg.extend_from_slice(public_bytes);

// Private scalar (mpint-encoded — RFC 5656 §3.1.2)
let scalar_bytes = private.as_slice();
let mpint = crate::crypto::ssh::encode_mpint_for_agent(scalar_bytes);
msg.extend_from_slice(&(mpint.len() as u32).to_be_bytes());
msg.extend_from_slice(&mpint);

// Comment
let comment = b"auths-key";
msg.extend_from_slice(&(comment.len() as u32).to_be_bytes());
msg.extend_from_slice(comment);
}
_ => {
return Err(AgentError::InvalidInput(
"Only Ed25519 keys are supported".to_string(),
"Only Ed25519 and NistP256 keys are supported".to_string(),
));
}
}
Expand Down Expand Up @@ -683,10 +695,8 @@ mod tests {
}

#[test]
fn test_extract_ed25519_seed_pkcs8() {
// This tests the PKCS#8 parsing with a valid structure
// For now, just test that invalid input returns an error
let result = extract_ed25519_seed(&[0u8; 10]);
fn test_parse_invalid_pkcs8() {
let result = auths_crypto::parse_key_material(&[0u8; 10]);
assert!(result.is_err());
}
}
Loading
Loading