diff --git a/bootstrap-languages/agent-language/adapter.ts b/bootstrap-languages/agent-language/adapter.ts index edf3ef5fc..8018cde7b 100644 --- a/bootstrap-languages/agent-language/adapter.ts +++ b/bootstrap-languages/agent-language/adapter.ts @@ -23,6 +23,57 @@ export default class ExpressionAdapterImpl implements ExpressionAdapter { return expression }; + + async addAuthorisedKey(did: string, key: string, name: string, proof: { authorising_key: string, signature: string, timestamp: string }): Promise { + // Zome validates and returns updated AgentExpressionData + const updatedData = await this.#DNA.call( + DNA_ROLE, + ZOME_NAME, + "add_authorised_key", + { did, key, name, proof } + ); + + // Re-sign with the agent's key and store the full expression + const signedExpression = this.#agent.createSignedExpression(updatedData); + await this.#DNA.call( + DNA_ROLE, + ZOME_NAME, + "create_agent_expression", + signedExpression + ); + + return signedExpression; + } + + async revokeKey(did: string, key: string, revokedByKey: string, signature: string, timestamp: string, reason?: string): Promise { + // Zome validates and returns updated AgentExpressionData + const updatedData = await this.#DNA.call( + DNA_ROLE, + ZOME_NAME, + "revoke_key", + { did, key, revokedByKey, signature, timestamp, reason: reason ?? null } + ); + + // Re-sign with the agent's key and store the full expression + const signedExpression = this.#agent.createSignedExpression(updatedData); + await this.#DNA.call( + DNA_ROLE, + ZOME_NAME, + "create_agent_expression", + signedExpression + ); + + return signedExpression; + } + + async isKeyValid(did: string, key: string): Promise { + return await this.#DNA.call( + DNA_ROLE, + ZOME_NAME, + "is_key_valid", + { did, key } + ); + } } class Sharing implements PublicSharing { diff --git a/bootstrap-languages/agent-language/hc-dna/Cargo.lock b/bootstrap-languages/agent-language/hc-dna/Cargo.lock index 0300b0e99..ffc99bb88 100644 --- a/bootstrap-languages/agent-language/hc-dna/Cargo.lock +++ b/bootstrap-languages/agent-language/hc-dna/Cargo.lock @@ -50,6 +50,7 @@ name = "agent_store" version = "0.0.1" dependencies = [ "agent_store_integrity", + "bs58", "chrono", "derive_more 0.99.20", "getrandom 0.3.4", @@ -78,7 +79,9 @@ name = "agent_store_tests" version = "0.1.0" dependencies = [ "agent_store_integrity", + "bs58", "chrono", + "ed25519-dalek 2.2.0", "futures", "hdi", "hdk", @@ -87,6 +90,7 @@ dependencies = [ "holochain_types", "holochain_zome_types", "pretty_assertions", + "rand 0.8.5", "serde", "serde_json", "tokio", @@ -696,6 +700,15 @@ dependencies = [ "objc2", ] +[[package]] +name = "bs58" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf88ba1141d185c399bee5288d850d63b8369520c1eafc32a0430b5b6c287bf4" +dependencies = [ + "tinyvec", +] + [[package]] name = "bumpalo" version = "3.19.0" @@ -1715,7 +1728,7 @@ dependencies = [ "libc", "option-ext", "redox_users", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -1943,7 +1956,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -5032,7 +5045,7 @@ version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -5880,7 +5893,7 @@ dependencies = [ "once_cell", "socket2 0.6.2", "tracing", - "windows-sys 0.59.0", + "windows-sys 0.60.2", ] [[package]] @@ -6601,7 +6614,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -7611,7 +7624,7 @@ dependencies = [ "getrandom 0.3.4", "once_cell", "rustix", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -8889,7 +8902,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] diff --git a/bootstrap-languages/agent-language/hc-dna/tests/sweettest/Cargo.toml b/bootstrap-languages/agent-language/hc-dna/tests/sweettest/Cargo.toml index 4cab84032..3538ce0e8 100644 --- a/bootstrap-languages/agent-language/hc-dna/tests/sweettest/Cargo.toml +++ b/bootstrap-languages/agent-language/hc-dna/tests/sweettest/Cargo.toml @@ -30,6 +30,9 @@ tokio = { version = "1", features = ["full"] } chrono = { version = "0.4", features = ["serde"] } futures = "0.3" uuid = { version = "1.0", features = ["v4"] } +ed25519-dalek = { version = "2", features = ["rand_core"] } +rand = "0.8" +bs58 = "0.5" [dev-dependencies] pretty_assertions = "1.4" diff --git a/bootstrap-languages/agent-language/hc-dna/tests/sweettest/src/integration_tests.rs b/bootstrap-languages/agent-language/hc-dna/tests/sweettest/src/integration_tests.rs index d6cb61fe1..5815277e9 100644 --- a/bootstrap-languages/agent-language/hc-dna/tests/sweettest/src/integration_tests.rs +++ b/bootstrap-languages/agent-language/hc-dna/tests/sweettest/src/integration_tests.rs @@ -4,3 +4,4 @@ mod utils; mod test_agent_expression; +mod test_multi_key; diff --git a/bootstrap-languages/agent-language/hc-dna/tests/sweettest/src/test_multi_key.rs b/bootstrap-languages/agent-language/hc-dna/tests/sweettest/src/test_multi_key.rs new file mode 100644 index 000000000..98baae9f0 --- /dev/null +++ b/bootstrap-languages/agent-language/hc-dna/tests/sweettest/src/test_multi_key.rs @@ -0,0 +1,516 @@ +//! Tests for multi-key agent identity support with Ed25519 signature verification + +use crate::utils::{call_zome, create_test_agent_expression, setup_1_conductor}; +use agent_store_integrity::{ + AgentExpressionData, + AddAuthorisedKeyInput, AgentExpression, IsKeyValidInput, KeyAuthorisation, RevokeKeyInput, +}; +use ed25519_dalek::{Signer, SigningKey}; +use rand::rngs::OsRng; + +/// Helper: generate an Ed25519 keypair and return (multibase_key_string, signing_key) +fn generate_test_keypair() -> (String, SigningKey) { + let signing_key = SigningKey::generate(&mut OsRng); + let verifying_key = signing_key.verifying_key(); + // Encode as multibase (z = base58btc) with Ed25519 multicodec prefix (0xed 0x01) + let mut prefixed = vec![0xed, 0x01]; + prefixed.extend_from_slice(verifying_key.as_bytes()); + let encoded = format!("z{}", bs58::encode(&prefixed).into_string()); + (encoded, signing_key) +} + +/// Helper: sign (subject_key + did + timestamp) and return hex-encoded signature +fn sign_key_message(signing_key: &SigningKey, subject_key: &str, did: &str, timestamp: &str) -> String { + let message = format!("{}{}{}", subject_key, did, timestamp); + let signature = signing_key.sign(message.as_bytes()); + hex::encode(signature.to_bytes()) +} + +/// Simple hex encoding (tests only) +mod hex { + pub fn encode(bytes: &[u8]) -> String { + bytes.iter().map(|b| format!("{:02x}", b)).collect() + } +} + +/// Test that creating an agent expression with a did:key DID auto-populates root key +#[tokio::test(flavor = "multi_thread")] +async fn test_create_expression_auto_populates_root_key() { + let (conductor, cell) = setup_1_conductor().await; + + let (root_key, _signing_key) = generate_test_keypair(); + let did = format!("did:key:{}", root_key); + let agent_expression = create_test_agent_expression(&did, None); + + let _: () = call_zome(&conductor, &cell, "create_agent_expression", agent_expression).await; + + let retrieved: Option = + call_zome(&conductor, &cell, "get_agent_expression", did.clone()).await; + + let expr = retrieved.expect("Should exist"); + assert_eq!(expr.data.authorised_keys.len(), 1, "Should have root key auto-populated"); + assert_eq!(expr.data.authorised_keys[0].key, root_key); + assert_eq!(expr.data.authorised_keys[0].name, "Root Key"); + assert_eq!(expr.data.authorised_keys[0].proof.signature, "self"); +} + +/// Test that creating with a non-did:key DID does NOT auto-populate +#[tokio::test(flavor = "multi_thread")] +async fn test_create_expression_non_did_key_no_auto_populate() { + let (conductor, cell) = setup_1_conductor().await; + + let did = "did:test:alice"; + let agent_expression = create_test_agent_expression(did, None); + + let _: () = call_zome(&conductor, &cell, "create_agent_expression", agent_expression).await; + + let retrieved: Option = + call_zome(&conductor, &cell, "get_agent_expression", did.to_string()).await; + + let expr = retrieved.expect("Should exist"); + assert!( + expr.data.authorised_keys.is_empty(), + "Non did:key DID should not auto-populate keys" + ); +} + +/// Test adding an authorised key with a valid Ed25519 signature +#[tokio::test(flavor = "multi_thread")] +async fn test_add_authorised_key_valid_signature() { + let (conductor, cell) = setup_1_conductor().await; + + let (root_key, root_signing_key) = generate_test_keypair(); + let did = format!("did:key:{}", root_key); + let agent_expression = create_test_agent_expression(&did, None); + let _: () = call_zome(&conductor, &cell, "create_agent_expression", agent_expression).await; + + let (new_key, _new_signing_key) = generate_test_keypair(); + let timestamp = "2025-01-01T00:00:00Z"; + let signature = sign_key_message(&root_signing_key, &new_key, &did, timestamp); + + let input = AddAuthorisedKeyInput { + did: did.clone(), + key: new_key.clone(), + name: "My Phone".to_string(), + proof: KeyAuthorisation { + authorising_key: root_key.clone(), + signature, + timestamp: timestamp.to_string(), + }, + }; + + let result: AgentExpressionData = call_zome(&conductor, &cell, "add_authorised_key", input).await; + + assert_eq!(result.authorised_keys.len(), 2); + assert_eq!(result.authorised_keys[1].key, new_key); + assert_eq!(result.authorised_keys[1].name, "My Phone"); +} + +/// Test adding a key with an invalid/tampered signature fails +#[tokio::test(flavor = "multi_thread")] +#[should_panic(expected = "Invalid signature")] +async fn test_add_authorised_key_invalid_signature_fails() { + let (conductor, cell) = setup_1_conductor().await; + + let (root_key, _root_signing_key) = generate_test_keypair(); + let did = format!("did:key:{}", root_key); + let agent_expression = create_test_agent_expression(&did, None); + let _: () = call_zome(&conductor, &cell, "create_agent_expression", agent_expression).await; + + let (new_key, _) = generate_test_keypair(); + let timestamp = "2025-01-01T00:00:00Z"; + + // Use a different key to sign (wrong signer) + let (_other_key, other_signing_key) = generate_test_keypair(); + let bad_signature = sign_key_message(&other_signing_key, &new_key, &did, timestamp); + + let input = AddAuthorisedKeyInput { + did: did.clone(), + key: new_key.clone(), + name: "Attacker".to_string(), + proof: KeyAuthorisation { + authorising_key: root_key.clone(), + signature: bad_signature, + timestamp: timestamp.to_string(), + }, + }; + + let _: AgentExpressionData = call_zome(&conductor, &cell, "add_authorised_key", input).await; +} + +/// Test adding a key with a tampered message (wrong key in message) fails +#[tokio::test(flavor = "multi_thread")] +#[should_panic(expected = "Invalid signature")] +async fn test_add_authorised_key_tampered_message_fails() { + let (conductor, cell) = setup_1_conductor().await; + + let (root_key, root_signing_key) = generate_test_keypair(); + let did = format!("did:key:{}", root_key); + let agent_expression = create_test_agent_expression(&did, None); + let _: () = call_zome(&conductor, &cell, "create_agent_expression", agent_expression).await; + + let (new_key, _) = generate_test_keypair(); + let timestamp = "2025-01-01T00:00:00Z"; + + // Sign over a different key than what we submit + let (different_key, _) = generate_test_keypair(); + let signature = sign_key_message(&root_signing_key, &different_key, &did, timestamp); + + let input = AddAuthorisedKeyInput { + did: did.clone(), + key: new_key.clone(), // Different from what was signed + name: "Tampered".to_string(), + proof: KeyAuthorisation { + authorising_key: root_key.clone(), + signature, + timestamp: timestamp.to_string(), + }, + }; + + let _: AgentExpressionData = call_zome(&conductor, &cell, "add_authorised_key", input).await; +} + +/// Test adding a key with an unauthorised (unknown) authorising key fails +#[tokio::test(flavor = "multi_thread")] +#[should_panic(expected = "Authorising key is not in the current authorised keys")] +async fn test_add_key_with_invalid_authorising_key_fails() { + let (conductor, cell) = setup_1_conductor().await; + + let (root_key, _) = generate_test_keypair(); + let did = format!("did:key:{}", root_key); + let agent_expression = create_test_agent_expression(&did, None); + let _: () = call_zome(&conductor, &cell, "create_agent_expression", agent_expression).await; + + let (unknown_key, unknown_signing_key) = generate_test_keypair(); + let (new_key, _) = generate_test_keypair(); + let timestamp = "2025-01-01T00:00:00Z"; + let signature = sign_key_message(&unknown_signing_key, &new_key, &did, timestamp); + + let input = AddAuthorisedKeyInput { + did: did.clone(), + key: new_key, + name: "Attacker".to_string(), + proof: KeyAuthorisation { + authorising_key: unknown_key, + signature, + timestamp: timestamp.to_string(), + }, + }; + + let _: AgentExpressionData = call_zome(&conductor, &cell, "add_authorised_key", input).await; +} + +/// Test adding a key that is already authorised fails +#[tokio::test(flavor = "multi_thread")] +#[should_panic(expected = "Key is already authorised")] +async fn test_add_duplicate_key_fails() { + let (conductor, cell) = setup_1_conductor().await; + + let (root_key, root_signing_key) = generate_test_keypair(); + let did = format!("did:key:{}", root_key); + let agent_expression = create_test_agent_expression(&did, None); + let _: () = call_zome(&conductor, &cell, "create_agent_expression", agent_expression).await; + + let timestamp = "2025-01-01T00:00:00Z"; + let signature = sign_key_message(&root_signing_key, &root_key, &did, timestamp); + + let input = AddAuthorisedKeyInput { + did: did.clone(), + key: root_key.clone(), + name: "Duplicate".to_string(), + proof: KeyAuthorisation { + authorising_key: root_key.clone(), + signature, + timestamp: timestamp.to_string(), + }, + }; + + let _: AgentExpressionData = call_zome(&conductor, &cell, "add_authorised_key", input).await; +} + +/// Test revoking a key with valid signature moves it from authorised to revoked +#[tokio::test(flavor = "multi_thread")] +async fn test_revoke_key_valid_signature() { + let (conductor, cell) = setup_1_conductor().await; + + let (root_key, root_signing_key) = generate_test_keypair(); + let did = format!("did:key:{}", root_key); + let agent_expression = create_test_agent_expression(&did, None); + let _: () = call_zome(&conductor, &cell, "create_agent_expression", agent_expression).await; + + // Add a second key + let (device_key, _device_signing_key) = generate_test_keypair(); + let add_ts = "2025-01-01T00:00:00Z"; + let add_sig = sign_key_message(&root_signing_key, &device_key, &did, add_ts); + + let add_input = AddAuthorisedKeyInput { + did: did.clone(), + key: device_key.clone(), + name: "Phone".to_string(), + proof: KeyAuthorisation { + authorising_key: root_key.clone(), + signature: add_sig, + timestamp: add_ts.to_string(), + }, + }; + let _: AgentExpressionData = call_zome(&conductor, &cell, "add_authorised_key", add_input).await; + + // Revoke the device key using root key + let revoke_ts = "2025-01-02T00:00:00Z"; + let revoke_sig = sign_key_message(&root_signing_key, &device_key, &did, revoke_ts); + + let revoke_input = RevokeKeyInput { + did: did.clone(), + key: device_key.clone(), + revoked_by_key: root_key.clone(), + signature: revoke_sig, + timestamp: revoke_ts.to_string(), + reason: Some("Lost device".to_string()), + }; + let result: AgentExpressionData = call_zome(&conductor, &cell, "revoke_key", revoke_input).await; + + assert_eq!(result.authorised_keys.len(), 1, "Revoked key should be removed"); + assert_eq!(result.revoked_keys.len(), 1); + assert_eq!(result.revoked_keys[0].revoked_key, device_key); + assert_eq!(result.revoked_keys[0].reason, Some("Lost device".to_string())); +} + +/// Test revoking a key with invalid signature fails +#[tokio::test(flavor = "multi_thread")] +#[should_panic(expected = "Invalid signature")] +async fn test_revoke_key_invalid_signature_fails() { + let (conductor, cell) = setup_1_conductor().await; + + let (root_key, root_signing_key) = generate_test_keypair(); + let did = format!("did:key:{}", root_key); + let agent_expression = create_test_agent_expression(&did, None); + let _: () = call_zome(&conductor, &cell, "create_agent_expression", agent_expression).await; + + // Add a second key + let (device_key, _) = generate_test_keypair(); + let add_ts = "2025-01-01T00:00:00Z"; + let add_sig = sign_key_message(&root_signing_key, &device_key, &did, add_ts); + + let add_input = AddAuthorisedKeyInput { + did: did.clone(), + key: device_key.clone(), + name: "Phone".to_string(), + proof: KeyAuthorisation { + authorising_key: root_key.clone(), + signature: add_sig, + timestamp: add_ts.to_string(), + }, + }; + let _: AgentExpressionData = call_zome(&conductor, &cell, "add_authorised_key", add_input).await; + + // Try to revoke with a bad signature + let revoke_ts = "2025-01-02T00:00:00Z"; + let bad_sig = "00".repeat(64); // 64 zero bytes — invalid signature + + let revoke_input = RevokeKeyInput { + did: did.clone(), + key: device_key.clone(), + revoked_by_key: root_key.clone(), + signature: bad_sig, + timestamp: revoke_ts.to_string(), + reason: None, + }; + let _: AgentExpressionData = call_zome(&conductor, &cell, "revoke_key", revoke_input).await; +} + +/// Test is_key_valid returns true for authorised keys, false for revoked/unknown +#[tokio::test(flavor = "multi_thread")] +async fn test_is_key_valid() { + let (conductor, cell) = setup_1_conductor().await; + + let (root_key, _) = generate_test_keypair(); + let did = format!("did:key:{}", root_key); + let agent_expression = create_test_agent_expression(&did, None); + let _: () = call_zome(&conductor, &cell, "create_agent_expression", agent_expression).await; + + // Root key should be valid + let valid: bool = call_zome( + &conductor, + &cell, + "is_key_valid", + IsKeyValidInput { + did: did.clone(), + key: root_key.clone(), + }, + ) + .await; + assert!(valid, "Root key should be valid"); + + // Unknown key should be invalid + let valid: bool = call_zome( + &conductor, + &cell, + "is_key_valid", + IsKeyValidInput { + did: did.clone(), + key: "unknown-key".to_string(), + }, + ) + .await; + assert!(!valid, "Unknown key should be invalid"); +} + +/// Test that a revoked key cannot be used to add new keys +#[tokio::test(flavor = "multi_thread")] +#[should_panic(expected = "Authorising key has been revoked")] +async fn test_revoked_key_cannot_add_new_keys() { + let (conductor, cell) = setup_1_conductor().await; + + let (root_key, root_signing_key) = generate_test_keypair(); + let did = format!("did:key:{}", root_key); + let agent_expression = create_test_agent_expression(&did, None); + let _: () = call_zome(&conductor, &cell, "create_agent_expression", agent_expression).await; + + // Add a secondary key + let (secondary_key, secondary_signing_key) = generate_test_keypair(); + let ts1 = "2025-01-01T00:00:00Z"; + let sig1 = sign_key_message(&root_signing_key, &secondary_key, &did, ts1); + + let add_input = AddAuthorisedKeyInput { + did: did.clone(), + key: secondary_key.clone(), + name: "Secondary".to_string(), + proof: KeyAuthorisation { + authorising_key: root_key.clone(), + signature: sig1, + timestamp: ts1.to_string(), + }, + }; + let _: AgentExpressionData = call_zome(&conductor, &cell, "add_authorised_key", add_input).await; + + // Revoke the secondary key + let ts2 = "2025-01-02T00:00:00Z"; + let revoke_sig = sign_key_message(&root_signing_key, &secondary_key, &did, ts2); + + let revoke_input = RevokeKeyInput { + did: did.clone(), + key: secondary_key.clone(), + revoked_by_key: root_key.clone(), + signature: revoke_sig, + timestamp: ts2.to_string(), + reason: None, + }; + let _: AgentExpressionData = call_zome(&conductor, &cell, "revoke_key", revoke_input).await; + + // Try to use revoked key to add another — should fail + let (new_key, _) = generate_test_keypair(); + let ts3 = "2025-01-03T00:00:00Z"; + let sig3 = sign_key_message(&secondary_signing_key, &new_key, &did, ts3); + + let add_with_revoked = AddAuthorisedKeyInput { + did: did.clone(), + key: new_key, + name: "Malicious".to_string(), + proof: KeyAuthorisation { + authorising_key: secondary_key, + signature: sig3, + timestamp: ts3.to_string(), + }, + }; + let _: AgentExpressionData = + call_zome(&conductor, &cell, "add_authorised_key", add_with_revoked).await; +} + +/// Test that is_key_valid returns false after a key is revoked +#[tokio::test(flavor = "multi_thread")] +async fn test_is_key_valid_after_revocation() { + let (conductor, cell) = setup_1_conductor().await; + + let (root_key, root_signing_key) = generate_test_keypair(); + let did = format!("did:key:{}", root_key); + let agent_expression = create_test_agent_expression(&did, None); + let _: () = call_zome(&conductor, &cell, "create_agent_expression", agent_expression).await; + + // Add a second key + let (device_key, _device_signing_key) = generate_test_keypair(); + let add_ts = "2025-01-01T00:00:00Z"; + let add_sig = sign_key_message(&root_signing_key, &device_key, &did, add_ts); + + let add_input = AddAuthorisedKeyInput { + did: did.clone(), + key: device_key.clone(), + name: "Phone".to_string(), + proof: KeyAuthorisation { + authorising_key: root_key.clone(), + signature: add_sig, + timestamp: add_ts.to_string(), + }, + }; + let _: AgentExpressionData = call_zome(&conductor, &cell, "add_authorised_key", add_input).await; + + // Verify device key is valid before revocation + let valid_before: bool = call_zome( + &conductor, + &cell, + "is_key_valid", + IsKeyValidInput { + did: did.clone(), + key: device_key.clone(), + }, + ) + .await; + assert!(valid_before, "Device key should be valid before revocation"); + + // Revoke the device key + let revoke_ts = "2025-01-02T00:00:00Z"; + let revoke_sig = sign_key_message(&root_signing_key, &device_key, &did, revoke_ts); + + let revoke_input = RevokeKeyInput { + did: did.clone(), + key: device_key.clone(), + revoked_by_key: root_key.clone(), + signature: revoke_sig, + timestamp: revoke_ts.to_string(), + reason: Some("Compromised".to_string()), + }; + let _: AgentExpressionData = call_zome(&conductor, &cell, "revoke_key", revoke_input).await; + + // Verify device key is now invalid + let valid_after: bool = call_zome( + &conductor, + &cell, + "is_key_valid", + IsKeyValidInput { + did: did.clone(), + key: device_key.clone(), + }, + ) + .await; + assert!(!valid_after, "Device key should be invalid after revocation"); + + // Root key should still be valid + let root_valid: bool = call_zome( + &conductor, + &cell, + "is_key_valid", + IsKeyValidInput { + did: did.clone(), + key: root_key.clone(), + }, + ) + .await; + assert!(root_valid, "Root key should still be valid"); +} + +/// Test backward compatibility: old-format expressions without authorised_keys +#[tokio::test(flavor = "multi_thread")] +async fn test_backward_compat_old_format() { + let (conductor, cell) = setup_1_conductor().await; + + let did = "did:test:legacy-agent"; + let agent_expression = create_test_agent_expression(did, Some("lang://old".to_string())); + let _: () = call_zome(&conductor, &cell, "create_agent_expression", agent_expression).await; + + let retrieved: Option = + call_zome(&conductor, &cell, "get_agent_expression", did.to_string()).await; + + let expr = retrieved.expect("Should exist"); + assert!(expr.data.authorised_keys.is_empty()); + assert!(expr.data.revoked_keys.is_empty()); + assert_eq!(expr.data.direct_message_language, Some("lang://old".to_string())); +} diff --git a/bootstrap-languages/agent-language/hc-dna/tests/sweettest/src/utils.rs b/bootstrap-languages/agent-language/hc-dna/tests/sweettest/src/utils.rs index af28f34aa..feeebd3b2 100644 --- a/bootstrap-languages/agent-language/hc-dna/tests/sweettest/src/utils.rs +++ b/bootstrap-languages/agent-language/hc-dna/tests/sweettest/src/utils.rs @@ -87,6 +87,8 @@ pub fn create_test_agent_expression(did: &str, direct_message_language: Option ExternResult { Ok(InitCallbackResult::Pass) } +/// Extract the key portion from a did:key: URI +fn extract_key_from_did(did: &str) -> Result { + if did.starts_with("did:key:") { + Ok(did.trim_start_matches("did:key:").to_string()) + } else { + Err(err(&format!("Cannot extract key from non did:key DID: {}", did))) + } +} + +/// Decode a multibase/multicodec Ed25519 public key string to raw 32 bytes. +/// +/// Expected format: base58btc-encoded (prefix 'z') with Ed25519 multicodec +/// prefix bytes `0xed 0x01` followed by 32 bytes of public key. +fn decode_ed25519_pubkey(key_str: &str) -> ExternResult<[u8; 32]> { + // Strip 'z' multibase prefix (base58btc) + let without_prefix = key_str.strip_prefix('z').unwrap_or(key_str); + let decoded = bs58::decode(without_prefix) + .into_vec() + .map_err(|e| err(&format!("Failed to base58-decode key: {}", e)))?; + + // Ed25519 multicodec: 0xed 0x01 + 32 bytes = 34 bytes + if decoded.len() == 34 && decoded[0] == 0xed && decoded[1] == 0x01 { + let mut key = [0u8; 32]; + key.copy_from_slice(&decoded[2..]); + Ok(key) + } else if decoded.len() == 32 { + // Raw 32-byte key without multicodec prefix + let mut key = [0u8; 32]; + key.copy_from_slice(&decoded); + Ok(key) + } else { + Err(err(&format!( + "Invalid Ed25519 key: expected 34 bytes (multicodec) or 32 bytes (raw), got {}", + decoded.len() + ))) + } +} + +/// Decode a hex-encoded Ed25519 signature to a Signature (64 bytes). +fn decode_signature(sig_hex: &str) -> ExternResult { + let bytes = hex_decode(sig_hex) + .map_err(|e| err(&format!("Failed to decode signature hex: {}", e)))?; + if bytes.len() != 64 { + return Err(err(&format!( + "Invalid signature length: expected 64 bytes, got {}", + bytes.len() + ))); + } + let mut sig = [0u8; 64]; + sig.copy_from_slice(&bytes); + Ok(Signature(sig)) +} + +/// Simple hex decoding (no external crate needed) +fn hex_decode(hex: &str) -> Result, String> { + if hex.len() % 2 != 0 { + return Err("Hex string has odd length".to_string()); + } + (0..hex.len()) + .step_by(2) + .map(|i| { + u8::from_str_radix(&hex[i..i + 2], 16) + .map_err(|e| format!("Invalid hex at position {}: {}", i, e)) + }) + .collect() +} + +/// Verify an Ed25519 signature using Holochain's built-in verify_signature_raw. +/// +/// ## Signature message format +/// +/// The signed message is the UTF-8 bytes of the concatenation: +/// ` + + ` +/// +/// Where: +/// - `key` is the key being added or revoked (the multibase-encoded string) +/// - `did` is the DID string (e.g., `did:key:z...`) +/// - `timestamp` is the ISO 8601 timestamp string (e.g., `2024-01-01T00:00:00Z`) +/// +/// Example: `"zABC123did:key:zXYZ7892024-01-01T00:00:00Z"` +fn verify_key_signature( + signing_key_str: &str, + signature_hex: &str, + subject_key: &str, + did: &str, + timestamp: &str, +) -> ExternResult { + // Skip verification for self-signed root keys + if signature_hex == "self" { + return Ok(true); + } + + let pubkey_bytes = decode_ed25519_pubkey(signing_key_str)?; + let agent_pubkey = AgentPubKey::from_raw_32(pubkey_bytes.to_vec()); + let signature = decode_signature(signature_hex)?; + + // Message = subject_key + did + timestamp (UTF-8 bytes) + let message = format!("{}{}{}", subject_key, did, timestamp); + + verify_signature_raw(agent_pubkey, signature, message.into_bytes()) +} + #[hdk_extern] -pub fn create_agent_expression(agent_expression: AgentExpression) -> ExternResult<()> { +pub fn create_agent_expression(mut agent_expression: AgentExpression) -> ExternResult<()> { + // Auto-populate authorised_keys with the DID root key if empty (migration path) + if agent_expression.data.authorised_keys.is_empty() { + if let Ok(root_key) = extract_key_from_did(&agent_expression.author) { + let now = chrono::Utc::now(); + agent_expression.data.authorised_keys.push(AuthorisedKey { + key: root_key.clone(), + name: "Root Key".to_string(), + added_at: now, + added_by: agent_expression.author.clone(), + proof: KeyAuthorisation { + authorising_key: root_key, + signature: "self".to_string(), + timestamp: now.to_rfc3339(), + }, + }); + } + } + let did = EntryTypes::Did(Did(agent_expression.author.clone())); let did_hash = hash_entry(&did)?; @@ -32,6 +155,153 @@ pub fn create_agent_expression(agent_expression: AgentExpression) -> ExternResul Ok(()) } +/// Helper to get current agent expression for a DID +fn get_current_expression(did: &str) -> ExternResult> { + let did_entry = Did(did.to_string()); + get_agent_expression(did_entry) +} + +#[hdk_extern] +pub fn add_authorised_key(input: AddAuthorisedKeyInput) -> ExternResult { + let current = get_current_expression(&input.did)? + .ok_or_else(|| err("Agent expression not found"))?; + + // Check that the authorising key is in the current authorised_keys + let authorising_key_valid = current + .data + .authorised_keys + .iter() + .any(|k| k.key == input.proof.authorising_key); + + if !authorising_key_valid { + return Err(err("Authorising key is not in the current authorised keys")); + } + + // Check key is not already revoked + let is_revoked = current + .data + .revoked_keys + .iter() + .any(|r| r.revoked_key == input.proof.authorising_key); + + if is_revoked { + return Err(err("Authorising key has been revoked")); + } + + // Verify the signature over (new_key + did + timestamp) + let sig_valid = verify_key_signature( + &input.proof.authorising_key, + &input.proof.signature, + &input.key, + &input.did, + &input.proof.timestamp, + )?; + if !sig_valid { + return Err(err("Invalid signature: Ed25519 verification failed for add_authorised_key proof")); + } + + // Check the new key isn't already authorised + let already_exists = current.data.authorised_keys.iter().any(|k| k.key == input.key); + if already_exists { + return Err(err("Key is already authorised")); + } + + let now = chrono::Utc::now(); + let new_key = AuthorisedKey { + key: input.key, + name: input.name, + added_at: now, + added_by: input.did.clone(), + proof: input.proof, + }; + + let mut new_data = current.data.clone(); + new_data.authorised_keys.push(new_key); + + // Return updated data — the adapter is responsible for signing and storing + Ok(new_data) +} + +#[hdk_extern] +pub fn revoke_key(input: RevokeKeyInput) -> ExternResult { + let current = get_current_expression(&input.did)? + .ok_or_else(|| err("Agent expression not found"))?; + + // Check the key exists in authorised_keys + let key_exists = current.data.authorised_keys.iter().any(|k| k.key == input.key); + if !key_exists { + return Err(err("Key not found in authorised keys")); + } + + // Check not already revoked + let already_revoked = current.data.revoked_keys.iter().any(|r| r.revoked_key == input.key); + if already_revoked { + return Err(err("Key is already revoked")); + } + + // Check that the revoking key is currently authorised + let revoker_valid = current + .data + .authorised_keys + .iter() + .any(|k| k.key == input.revoked_by_key); + if !revoker_valid { + return Err(err("Revoking key is not in the current authorised keys")); + } + + // Check revoking key is not itself revoked + let revoker_revoked = current + .data + .revoked_keys + .iter() + .any(|r| r.revoked_key == input.revoked_by_key); + if revoker_revoked { + return Err(err("Revoking key has been revoked")); + } + + // Verify the signature over (revoked_key + did + timestamp) + let sig_valid = verify_key_signature( + &input.revoked_by_key, + &input.signature, + &input.key, + &input.did, + &input.timestamp, + )?; + if !sig_valid { + return Err(err("Invalid signature: Ed25519 verification failed for revoke_key")); + } + + let now = chrono::Utc::now(); + let revocation = KeyRevocation { + revoked_key: input.key.clone(), + revoked_at: now, + revoked_by: input.did.clone(), + revoked_by_key: input.revoked_by_key, + signature: input.signature, + reason: input.reason, + }; + + let mut new_data = current.data.clone(); + new_data.authorised_keys.retain(|k| k.key != input.key); + new_data.revoked_keys.push(revocation); + + // Return updated data — the adapter is responsible for signing and storing + Ok(new_data) +} + +#[hdk_extern] +pub fn is_key_valid(input: IsKeyValidInput) -> ExternResult { + let current = match get_current_expression(&input.did)? { + Some(expr) => expr, + None => return Ok(false), + }; + + let in_authorised = current.data.authorised_keys.iter().any(|k| k.key == input.key); + let in_revoked = current.data.revoked_keys.iter().any(|r| r.revoked_key == input.key); + + Ok(in_authorised && !in_revoked) +} + #[hdk_extern] pub fn get_agent_expression(did: Did) -> ExternResult> { let expression_links = get_latest_link( diff --git a/bootstrap-languages/agent-language/hc-dna/zomes/agent_store_integrity/src/lib.rs b/bootstrap-languages/agent-language/hc-dna/zomes/agent_store_integrity/src/lib.rs index 5c4d8afd7..13e7339b9 100644 --- a/bootstrap-languages/agent-language/hc-dna/zomes/agent_store_integrity/src/lib.rs +++ b/bootstrap-languages/agent-language/hc-dna/zomes/agent_store_integrity/src/lib.rs @@ -56,6 +56,66 @@ pub struct AgentExpression { app_entry!(AgentExpression); +#[derive(Serialize, Deserialize, Clone, SerializedBytes, Debug)] +#[serde(rename_all = "camelCase")] +pub struct KeyAuthorisation { + pub authorising_key: String, + pub signature: String, + /// ISO 8601 timestamp that was included in the signed message + pub timestamp: String, +} + +#[derive(Serialize, Deserialize, Clone, SerializedBytes, Debug)] +#[serde(rename_all = "camelCase")] +pub struct AuthorisedKey { + pub key: String, + pub name: String, + pub added_at: DateTime, + pub added_by: String, + pub proof: KeyAuthorisation, +} + +#[derive(Serialize, Deserialize, Clone, SerializedBytes, Debug)] +#[serde(rename_all = "camelCase")] +pub struct KeyRevocation { + pub revoked_key: String, + pub revoked_at: DateTime, + pub revoked_by: String, + /// The authorised key that signed this revocation + pub revoked_by_key: String, + pub signature: String, + pub reason: Option, +} + +#[derive(Serialize, Deserialize, Clone, SerializedBytes, Debug)] +#[serde(rename_all = "camelCase")] +pub struct AddAuthorisedKeyInput { + pub did: String, + pub key: String, + pub name: String, + pub proof: KeyAuthorisation, +} + +#[derive(Serialize, Deserialize, Clone, SerializedBytes, Debug)] +#[serde(rename_all = "camelCase")] +pub struct RevokeKeyInput { + pub did: String, + pub key: String, + /// The authorised key used to sign the revocation + pub revoked_by_key: String, + pub signature: String, + /// ISO 8601 timestamp that was included in the signed message + pub timestamp: String, + pub reason: Option, +} + +#[derive(Serialize, Deserialize, Clone, SerializedBytes, Debug)] +#[serde(rename_all = "camelCase")] +pub struct IsKeyValidInput { + pub did: String, + pub key: String, +} + #[derive(Serialize, Deserialize, Clone, SerializedBytes, Debug)] pub struct AgentExpressionData { pub did: String, @@ -63,4 +123,8 @@ pub struct AgentExpressionData { #[serde(rename(serialize = "directMessageLanguage"))] #[serde(rename(deserialize = "directMessageLanguage"))] pub direct_message_language: Option, + #[serde(default, rename = "authorisedKeys")] + pub authorised_keys: Vec, + #[serde(default, rename = "revokedKeys")] + pub revoked_keys: Vec, } diff --git a/core/src/agent/Agent.ts b/core/src/agent/Agent.ts index 960de7851..03f0331ed 100644 --- a/core/src/agent/Agent.ts +++ b/core/src/agent/Agent.ts @@ -2,6 +2,86 @@ import { Field, ObjectType, InputType } from "type-graphql"; import { Perspective } from "../perspectives/Perspective"; import { ExpressionGeneric } from "../expression/Expression"; +@ObjectType() +export class KeyAuthorisation { + @Field() + authorisingKey: string; + + @Field() + signature: string; + + constructor(authorisingKey: string, signature: string) { + this.authorisingKey = authorisingKey; + this.signature = signature; + } +} + +@InputType() +export class KeyAuthorisationInput { + @Field() + authorisingKey: string; + + @Field() + signature: string; + + constructor(authorisingKey: string, signature: string) { + this.authorisingKey = authorisingKey; + this.signature = signature; + } +} + +@ObjectType() +export class AuthorisedKey { + @Field() + key: string; + + @Field() + name: string; + + @Field() + addedAt: string; + + @Field() + addedBy: string; + + @Field((type) => KeyAuthorisation) + proof: KeyAuthorisation; + + constructor(key: string, name: string, addedAt: string, addedBy: string, proof: KeyAuthorisation) { + this.key = key; + this.name = name; + this.addedAt = addedAt; + this.addedBy = addedBy; + this.proof = proof; + } +} + +@ObjectType() +export class KeyRevocation { + @Field() + revokedKey: string; + + @Field() + revokedAt: string; + + @Field() + revokedBy: string; + + @Field() + signature: string; + + @Field({ nullable: true }) + reason?: string; + + constructor(revokedKey: string, revokedAt: string, revokedBy: string, signature: string, reason?: string) { + this.revokedKey = revokedKey; + this.revokedAt = revokedAt; + this.revokedBy = revokedBy; + this.signature = signature; + this.reason = reason; + } +} + /** AD4M's representation of an Agent * * AD4M Agents are build around DIDs, which are used to identify and authenticate the Agent. @@ -39,6 +119,14 @@ export class Agent { @Field({ nullable: true }) directMessageLanguage?: string; + /** List of authorised keys for this agent identity */ + @Field((type) => [AuthorisedKey], { nullable: true }) + authorisedKeys?: AuthorisedKey[]; + + /** List of revoked keys for this agent identity */ + @Field((type) => [KeyRevocation], { nullable: true }) + revokedKeys?: KeyRevocation[]; + constructor(did: string, perspective?: Perspective) { this.did = did; if (perspective) { diff --git a/core/src/agent/AgentClient.ts b/core/src/agent/AgentClient.ts index 71f33d16a..02fed97d3 100644 --- a/core/src/agent/AgentClient.ts +++ b/core/src/agent/AgentClient.ts @@ -6,8 +6,11 @@ import { Apps, AuthInfo, AuthInfoInput, + AuthorisedKey, EntanglementProof, EntanglementProofInput, + KeyAuthorisationInput, + KeyRevocation, UserCreationResult, } from "./Agent"; import { AgentStatus } from "./AgentStatus"; @@ -29,6 +32,23 @@ const AGENT_SUBITEMS = ` } } } + authorisedKeys { + key + name + addedAt + addedBy + proof { + authorisingKey + signature + } + } + revokedKeys { + revokedKey + revokedAt + revokedBy + signature + reason + } `; const Apps_FIELDS = ` @@ -119,6 +139,8 @@ export class AgentClient { ); let agentObject = new Agent(agent.did, agent.perspective); agentObject.directMessageLanguage = agent.directMessageLanguage; + agentObject.authorisedKeys = agent.authorisedKeys; + agentObject.revokedKeys = agent.revokedKeys; return agentObject; } @@ -236,6 +258,8 @@ export class AgentClient { const a = agentUpdatePublicPerspective; const agent = new Agent(a.did, a.perspective); agent.directMessageLanguage = a.directMessageLanguage; + agent.authorisedKeys = a.authorisedKeys; + agent.revokedKeys = a.revokedKeys; return agent; } @@ -286,9 +310,88 @@ export class AgentClient { const a = agentUpdateDirectMessageLanguage; const agent = new Agent(a.did, a.perspective); agent.directMessageLanguage = a.directMessageLanguage; + agent.authorisedKeys = a.authorisedKeys; + agent.revokedKeys = a.revokedKeys; return agent; } + async addAuthorisedKey(key: string, name: string, proof: KeyAuthorisationInput): Promise { + const { agentAddAuthorisedKey } = unwrapApolloResult( + await this.#apolloClient.mutate({ + mutation: gql`mutation agentAddAuthorisedKey($key: String!, $name: String!, $proof: KeyAuthorisationInput!) { + agentAddAuthorisedKey(key: $key, name: $name, proof: $proof) { + ${AGENT_SUBITEMS} + } + }`, + variables: { key, name, proof }, + }) + ); + return agentAddAuthorisedKey as Agent; + } + + async revokeKey(key: string, reason?: string): Promise { + const { agentRevokeKey } = unwrapApolloResult( + await this.#apolloClient.mutate({ + mutation: gql`mutation agentRevokeKey($key: String!, $reason: String) { + agentRevokeKey(key: $key, reason: $reason) { + ${AGENT_SUBITEMS} + } + }`, + variables: { key, reason }, + }) + ); + return agentRevokeKey as Agent; + } + + async authorisedKeys(): Promise { + const { agentAuthorisedKeys } = unwrapApolloResult( + await this.#apolloClient.query({ + query: gql`query agentAuthorisedKeys { + agentAuthorisedKeys { + key + name + addedAt + addedBy + proof { + authorisingKey + signature + } + } + }`, + }) + ); + return agentAuthorisedKeys; + } + + async revokedKeys(): Promise { + const { agentRevokedKeys } = unwrapApolloResult( + await this.#apolloClient.query({ + query: gql`query agentRevokedKeys { + agentRevokedKeys { + revokedKey + revokedAt + revokedBy + signature + reason + } + }`, + }) + ); + return agentRevokedKeys; + } + + async isKeyValid(did: string, key: string): Promise { + const { agentIsKeyValid } = unwrapApolloResult( + await this.#apolloClient.query({ + query: gql`query agentIsKeyValid($did: String!, $key: String!) { + agentIsKeyValid(did: $did, key: $key) + }`, + variables: { did, key }, + }) + ); + return agentIsKeyValid; + } + async addEntanglementProofs( proofs: EntanglementProofInput[] ): Promise { diff --git a/core/src/agent/AgentResolver.ts b/core/src/agent/AgentResolver.ts index 2a6d1247a..6d226ce36 100644 --- a/core/src/agent/AgentResolver.ts +++ b/core/src/agent/AgentResolver.ts @@ -12,8 +12,12 @@ import { AgentSignature, Apps, AuthInfoInput, + AuthorisedKey, EntanglementProof, EntanglementProofInput, + KeyAuthorisation, + KeyAuthorisationInput, + KeyRevocation, } from "./Agent"; import { AgentStatus } from "./AgentStatus"; import { AGENT_STATUS_CHANGED, AGENT_UPDATED, APPS_CHANGED } from "../PubSub"; @@ -260,4 +264,44 @@ export default class AgentResolver { agentSignMessage(@Arg("message") message: string): AgentSignature { return new AgentSignature("test-message-signature", "test-public-key"); } + + @Mutation((returns) => Agent) + agentAddAuthorisedKey( + @Arg("key") key: string, + @Arg("name") name: string, + @Arg("proof") proof: KeyAuthorisationInput + ): Agent { + // TODO: Wire through to executor's agent subsystem → adapter → zome + throw new Error("Not implemented - requires executor wiring for multi-key agent identity"); + } + + @Mutation((returns) => Agent) + agentRevokeKey( + @Arg("key") key: string, + @Arg("reason", { nullable: true }) reason?: string + ): Agent { + // TODO: Wire through to executor's agent subsystem → adapter → zome + throw new Error("Not implemented - requires executor wiring for multi-key agent identity"); + } + + @Query((returns) => [AuthorisedKey]) + agentAuthorisedKeys(): AuthorisedKey[] { + // TODO: Wire through to executor's agent subsystem → adapter → zome + throw new Error("Not implemented - requires executor wiring for multi-key agent identity"); + } + + @Query((returns) => [KeyRevocation]) + agentRevokedKeys(): KeyRevocation[] { + // TODO: Wire through to executor's agent subsystem → adapter → zome + throw new Error("Not implemented - requires executor wiring for multi-key agent identity"); + } + + @Query((returns) => Boolean) + agentIsKeyValid( + @Arg("did") did: string, + @Arg("key") key: string + ): Boolean { + // TODO: Wire through to executor's agent subsystem → adapter → zome + throw new Error("Not implemented - requires executor wiring for multi-key agent identity"); + } }