From defd3fa5803f9f41d2cef895729734b8db87334d Mon Sep 17 00:00:00 2001 From: Charon Date: Wed, 16 Sep 2026 15:45:26 -0500 Subject: [PATCH] =?UTF-8?q?feat(identity):=20RV14=20=E2=80=94=20una=20iden?= =?UTF-8?q?tidad=20sobrevive=20a=20perder=20su=20llave,=20y=20la=20revisio?= =?UTF-8?q?n=20encontro=20que=20el=20storage=20salia=20gratis?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit QUE NACE. rotate_agent_key (call_index 1 de agent-identity) y la runtime API AgentKeyApi::current_key. Hasta hoy un DID quedaba atado de hecho a su llave de nacimiento: no se re-derivaba, pero tampoco habia forma de pasar la autoridad a otra llave. Perder o filtrar la llave obligaba a registrarse otra vez con OTRO DID y perder credenciales, creditos e historial. Ahora el DID es de por vida y lo que rota es que llave manda sobre el. Es la dependencia de dos hilos abiertos: el DID del niño (Marihan) y los creditos de referido indexados por DID (Codex). Diseño: CurrentKey[did] (ausente = manda la de nacimiento, que ya vivia en creator; sin migracion), KeyToDid[llave] (una llave manda sobre un solo DID en toda la vida de la cadena; las retiradas quedan quemadas) y RotationNonce[did]. Dos firmas sobre DOMAIN || genesis || did || vigente || nueva || nonce || who: la vigente autoriza, la nueva prueba control. LO QUE LA REVISION ADVERSARIAL ENCONTRO ANTES DE PUBLICAR. Cinco lentes, 18 hallazgos, 17 confirmados. Todos arreglados aqui y cada arreglo con su contrafactual visto en ROJO: 1. DbWeight = () EN TODO EL RUNTIME. frame_system heredaba el valor por defecto de SolochainDefaultConfig sin declararlo. Cada reads(n)/writes(n) de TODOS los weights.rs valia cero: la E/S de storage no se cobraba en fees ni llenaba el bloque. Ahora RocksDbWeight, con un test que reprueba si vuelve a cero. ESTO CORRIGE LO QUE YO DIJE EN RV13: los reads/writes que medi alli no tenian efecto en el runtime. Lo que RV13 si arreglo es el proof_size y el ref_time de ejecucion, que no pasan por DbWeight. 2. LLAVES DE ORDEN PEQUEÑO. El verificador del host (ed25519-zebra, ZIP215) las acepta. Con el punto identidad como llave y la firma trivial R=identidad s=0, la «prueba de control» se superaba sin llave privada, en rotacion Y en registro: el DID quedaba bajo una llave con la que cualquiera firma en su nombre. Para un DID de un niño en Ethos eso es suplantacion. Ahora is_strong_ed25519_key descomprime el punto, exige codificacion canonica y rechaza orden pequeño (curve25519-dalek, que ya iba dentro del WASM). 3. EL REGISTRO ESCRIBIA KeyToDid PERO NO LO CONSULTABA. Una llave a la que otro DID ya habia rotado fundaba un DID nuevo y pisaba el indice. Hallado cinco veces por separado. Va detras de la colision de DID para no cambiar el error documentado de scenario_12. 4. REPLAY ENTRE CADENAS: el mensaje no llevaba genesis. Ahora si, mismo patron que provenance. Y menores: el evento nuevo va al final del enum (insertarlo en medio corria el indice SCALE de AgentUpdated); la insercion redundante de la llave vieja ya no pisa entradas; la doc dice que perder la CUENTA tambien bloquea la rotacion, no solo perder la llave; el comentario de creator decia «la cuenta que registro» y el codigo guarda la llave de nacimiento. DOS TESTS MIOS PASABAN POR LA RAZON EQUIVOCADA, y lo digo. El de «llave retirada quemada» volvia a la de nacimiento, que atrapa otra clausula. El del ladron firmaba con la llave vigente nueva, algo que un ladron real no tiene; con el pallet roto seguia verde en su mitad de ataque. Reescritos, y ahora el rojo sale de la mitad correcta (verificado por linea del panic). LO QUE QUEDA ABIERTO Y NO ES DE ESTE COMMIT: tras rotar, derive(llave_vigente) ya no es el DID. OCF y Palace hoy DERIVAN el identificador; para un usuario que roto tienen que RESOLVERLO contra KeyToDid. Es una enmienda al DC de unificacion con sus firmantes. La recuperacion por guardianes va aparte. Y KeyToDid crece sin deposito; se reevalua ahora que las escrituras cuestan de verdad. Medido sobre el WASM real (bencher 0.15.0, Mac, no canonico): rotate 97 us, 11 lecturas, 5 escrituras, proof 6100. 318 tests verde. RV13 -> RV14. TRANSACTION_VERSION intacto: call nueva en indice nuevo. Co-Authored-By: Claude Opus 5 --- Cargo.lock | 1 + Cargo.toml | 3 + crates/origin-chain-agent-identity/Cargo.toml | 6 + .../src/benchmarking.rs | 50 ++ crates/origin-chain-agent-identity/src/lib.rs | 337 ++++++++++- .../origin-chain-agent-identity/src/mock.rs | 54 ++ .../src/tests_rotation.rs | 533 ++++++++++++++++++ .../src/weights.rs | 65 ++- crates/origin-chain-node/src/main.rs | 3 +- crates/origin-chain-runtime/src/configs.rs | 13 + crates/origin-chain-runtime/src/lib.rs | 69 ++- crates/origin-chain-types/src/identity.rs | 32 +- 12 files changed, 1146 insertions(+), 20 deletions(-) create mode 100644 crates/origin-chain-agent-identity/src/tests_rotation.rs diff --git a/Cargo.lock b/Cargo.lock index 789a294..19ed160 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6383,6 +6383,7 @@ name = "origin-chain-agent-identity" version = "0.0.1" dependencies = [ "blake3", + "curve25519-dalek", "frame-benchmarking", "frame-support", "frame-system", diff --git a/Cargo.toml b/Cargo.toml index 459220c..10a2873 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -188,6 +188,9 @@ scale-info = { version = "2.11", default-features = false, features = ["derive"] # ============================================================ ed25519-dalek = { version = "2", default-features = false } blake3 = { version = "1", default-features = false } +# Rechazo de llaves Ed25519 débiles (orden pequeño / codificación no canónica) en +# agent-identity. Ya estaba en el grafo del WASM vía schnorrkel y ed25519-zebra. +curve25519-dalek = { version = "4.1.3", default-features = false } # ============================================================ # Error handling + serialization canonical diff --git a/crates/origin-chain-agent-identity/Cargo.toml b/crates/origin-chain-agent-identity/Cargo.toml index 692a34f..34a1b6d 100644 --- a/crates/origin-chain-agent-identity/Cargo.toml +++ b/crates/origin-chain-agent-identity/Cargo.toml @@ -23,6 +23,12 @@ sp-api = { workspace = true } # Crypto canonical (BLAKE3 hash derivation para DID) blake3 = { workspace = true } +# Rechazo de llaves débiles: descomprimir el punto, exigir codificación canónica y +# que no sea de orden pequeño. El verificador del host (ed25519-zebra, reglas ZIP215) +# ACEPTA esas llaves por compatibilidad de consenso, así que el pallet tiene que +# rechazarlas antes de que una firma trivial «pruebe» control sin llave privada. +curve25519-dalek = { workspace = true } + # Substrate primitives canonical (BoundedVec consumer) sp-core = { workspace = true } sp-runtime = { workspace = true } diff --git a/crates/origin-chain-agent-identity/src/benchmarking.rs b/crates/origin-chain-agent-identity/src/benchmarking.rs index 5a226e5..ffede9b 100644 --- a/crates/origin-chain-agent-identity/src/benchmarking.rs +++ b/crates/origin-chain-agent-identity/src/benchmarking.rs @@ -19,7 +19,9 @@ use super::*; use frame_benchmarking::v2::*; use frame_support::pallet_prelude::Decode; +use frame_system::pallet_prelude::BlockNumberFor; use frame_system::RawOrigin; +use sp_runtime::traits::Zero; // `AgentKind` llega vía `super::*` (re-exportado en el crate root). use alloc::vec::Vec; @@ -92,5 +94,53 @@ mod benchmarks { assert!(Identities::::contains_key(&who)); } + /// Rotación ALICE→DAVE con las dos firmas dorados (`crate::benchmark_golden`). + /// + /// Mide el camino FELIZ completo: dos verificaciones Ed25519 y las cuatro + /// escrituras. Si las firmas quedaran viejas, la primera verificación fallaría y + /// el benchmark mediría el error temprano — por eso el assert del final, y por + /// eso un test fija los vectores contra el builder del mensaje. + #[benchmark] + fn rotate_agent_key() { + let who: T::AccountId = + Decode::decode(&mut &CALLER[..]).expect("AccountId32 decodes from 32 bytes"); + // El mensaje de rotación lleva el génesis (anti-replay entre cadenas). Las firmas + // doradas se calcularon con `0xAB×32`, el mismo valor que siembra el mock; en el + // runtime real `block_hash(0)` es otro, así que hay que sembrarlo aquí. + // + // Sin esta línea el benchmark pasaba en el mock y FALLABA en el WASM real con + // `InvalidCurrentKeySignature`: lo cazó el bencher en el primer intento de medir + // RV14. Si no fallara ruidoso, habría medido el camino de error y producido un + // peso falso — por eso el assert del final existe. + let genesis: T::Hash = + Decode::decode(&mut &[0xABu8; 32][..]).expect("H256 decodes from 32 bytes"); + frame_system::BlockHash::::insert(BlockNumberFor::::zero(), genesis); + // Precondición: ALICE registrada con su llave de nacimiento. + Pallet::::register_agent_did( + RawOrigin::Signed(who.clone()).into(), + to_32(BENCH_PUBKEY), + AgentKind::Human, + Vec::new(), + to_64(BENCH_SIG), + ) + .expect("registro dorado válido"); + + let new_key = to_32(crate::benchmark_golden::ROT_DAVE_PUBKEY); + let sig_current = to_64(crate::benchmark_golden::ROT_SIG_CURRENT); + let sig_new = to_64(crate::benchmark_golden::ROT_SIG_NEW); + + #[extrinsic_call] + rotate_agent_key( + RawOrigin::Signed(who.clone()), + new_key, + sig_current, + sig_new, + ); + + // Si esto falla, se midió el camino de error y el peso es falso. + let did = crate::derive_did(&to_32(BENCH_PUBKEY)); + assert_eq!(CurrentKey::::get(did), Some(new_key)); + } + impl_benchmark_test_suite!(Pallet, crate::mock::new_test_ext(), crate::mock::Test); } diff --git a/crates/origin-chain-agent-identity/src/lib.rs b/crates/origin-chain-agent-identity/src/lib.rs index 1219993..2f40f00 100644 --- a/crates/origin-chain-agent-identity/src/lib.rs +++ b/crates/origin-chain-agent-identity/src/lib.rs @@ -127,6 +127,83 @@ pub fn agent_did_register_message(who_encoded: &[u8]) -> alloc::vec::Vec { msg } +/// Dominio de la firma de ROTACIÓN de llave. Distinto del de registro a propósito: +/// una firma de registro nunca puede reutilizarse como autorización de rotación. +pub const AGENT_KEY_ROTATE_DOMAIN: &[u8] = b"origin-chain/agent-key-rotate/v1"; + +/// Mensaje que firman AMBAS llaves —la vigente y la nueva— para rotar. +/// +/// `AGENT_KEY_ROTATE_DOMAIN || genesis || did || llave_vigente || llave_nueva || nonce_le || who` +/// +/// Cada campo cierra un ataque distinto, y por eso están todos: +/// - `genesis`: la firma solo vale en ESTA cadena. Sin él, una rotación firmada en +/// testnet se podía reenviar en mainnet si alguien reusaba cuenta y llaves, y quien +/// robara la cuenta en mainnet podía aplicar una autorización vieja de la llave sin +/// tener la llave. Encontrado por la revisión adversarial de RV14, medido con dos +/// entornos de prueba independientes. Mismo patrón que `origin-chain-provenance`. +/// - `did`: la firma solo vale para ESTA identidad. +/// - `llave_vigente`: ata la autorización a la llave que manda HOY; tras rotar, la +/// misma firma ya no describe el estado y no puede reutilizarse. +/// - `llave_nueva`: la nueva llave prueba que su dueño consiente ser la autoridad. +/// - `nonce`: anti-replay explícito, independiente de lo anterior. +/// - `who`: la firma queda atada a la cuenta que la envía; nadie puede tomar una +/// rotación firmada del mempool y enviarla desde otra cuenta. +/// +/// Una sola fuente de verdad para el verificador on-chain y para el firmante. +pub fn agent_key_rotate_message( + genesis_hash: &[u8; 32], + did: &[u8; 32], + current_key: &[u8; 32], + new_key: &[u8; 32], + nonce: u32, + who_encoded: &[u8], +) -> alloc::vec::Vec { + let mut msg = alloc::vec::Vec::with_capacity( + AGENT_KEY_ROTATE_DOMAIN.len() + 32 * 4 + 4 + who_encoded.len(), + ); + msg.extend_from_slice(AGENT_KEY_ROTATE_DOMAIN); + msg.extend_from_slice(genesis_hash); + msg.extend_from_slice(did); + msg.extend_from_slice(current_key); + msg.extend_from_slice(new_key); + msg.extend_from_slice(&nonce.to_le_bytes()); + msg.extend_from_slice(who_encoded); + msg +} + +/// ¿Es esta llave Ed25519 una llave con la que solo su dueño puede firmar? +/// +/// Exige tres cosas: que el punto esté en la curva, que su codificación sea la +/// CANÓNICA (volver a comprimirlo da los mismos 32 bytes) y que **no sea de orden +/// pequeño**. +/// +/// **Por qué hace falta, y es grave:** el verificador del host +/// (`sp_io::crypto::ed25519_verify`, ed25519-zebra con reglas ZIP215) ACEPTA llaves de +/// orden pequeño por compatibilidad de consenso. Con el punto identidad como llave +/// (`01 00..00`) y la firma trivial `R = identidad, s = 0`, la verificación da `true` +/// **para cualquier mensaje**. Sin este chequeo, la «prueba de control de la llave +/// nueva» de una rotación —y la firma de registro— se superaban sin llave privada, y +/// el DID quedaba bajo una llave con la que CUALQUIERA firma en su nombre. Para +/// Ethos, donde un DID puede ser el de un niño, eso es suplantación. +/// +/// Encontrado por la revisión adversarial de RV14 y medido con sonda: la rotación y el +/// registro devolvían `Ok` con `01 00..00`, con el punto de orden 2 (`ec ff..ff 7f`) y +/// con una codificación NO canónica de la identidad (`ee ff..ff 7f`). Tener que +/// cubrir codificaciones no canónicas es la razón para comprimir de vuelta en vez de +/// mantener una lista: una lista escrita a mano se deja fuera alguna. +/// +/// No se exige `is_torsion_free`: una llave de orden mixto no se puede falsificar (hace +/// falta el logaritmo discreto de su componente de orden primo) y ese chequeo cuesta +/// una multiplicación escalar completa por extrinsic. +pub fn is_strong_ed25519_key(key: &[u8; 32]) -> bool { + use curve25519_dalek::edwards::CompressedEdwardsY; + let compressed = CompressedEdwardsY(*key); + let Some(point) = compressed.decompress() else { + return false; + }; + point.compress().to_bytes() == *key && !point.is_small_order() +} + /// Runtime API canonical para cross-pillar consumers + light clients. /// /// Permite query canonical de agent identity canonical sin necesidad de full @@ -149,6 +226,10 @@ sp_api::decl_runtime_apis! { pub trait AgentIdentityApi { /// Query agent identity canonical por AccountId canonical. /// + /// ⚠️ El campo `creator` que devuelve es la llave de NACIMIENTO y no cambia al + /// rotar. **No lo uses para verificar firmas del titular**: usa + /// `AgentKeyApi::current_key`. + /// /// Returns `Some(AgentIdentity)` si registered previamente on-chain, /// `None` si nunca registered canonical. Read-only canonical sin /// transaction fee canonical (off-chain via RPC). @@ -156,6 +237,36 @@ sp_api::decl_runtime_apis! { } } +sp_api::decl_runtime_apis! { + /// Resolución DID → llave vigente. + /// + /// Es la pregunta que hace cualquiera que verifique una firma del titular de un + /// DID (Ethos para `subject_pubkey`, Codex para créditos, un light client): no + /// «¿qué llave generó este DID?» sino «¿qué llave manda HOY sobre él?». Tras una + /// rotación son distintas, y un consumidor que cachee la llave de nacimiento + /// dejaría de validar al titular legítimo. + /// + /// Trait aparte de `AgentIdentityApi` a propósito: añadir un método a una API ya + /// publicada obliga a versionarla, y una API nueva no toca a sus consumidores. + /// + /// **Tres reglas para quien consuma esta API**, y las tres salieron de la revisión + /// adversarial de RV14: + /// 1. **Responde «ahora».** Para validar una firma hecha en el bloque N, llama a esta + /// API EN el hash del bloque N (toda runtime API de Substrate lo admite), o + /// reconstruye la historia con los eventos `KeyRotated`. Verificar una firma + /// antigua contra la llave de hoy la rechaza aunque fuera legítima. + /// 2. **Verifica con reglas estrictas** (`verify_strict` en dalek o equivalente). La + /// cadena ya rechaza llaves de orden pequeño, pero un verificador permisivo fuera + /// de cadena sigue aceptando firmas maleables. + /// 3. **Separa dominios.** Esta misma llave firma cosas fuera de cadena. Si algún + /// protocolo le hace firmar bytes elegidos por otro sin prefijo propio, quien tenga + /// solo la cuenta podría conseguir una firma de rotación. + pub trait AgentKeyApi { + /// Llave Ed25519 vigente de `did`, o `None` si el DID no está registrado. + fn current_key(did: origin_chain_types::Did) -> Option<[u8; 32]>; + } +} + #[frame_support::pallet] pub mod pallet { use alloc::vec::Vec; @@ -163,7 +274,7 @@ pub mod pallet { use frame_system::pallet_prelude::*; use origin_chain_types::{AccountId, AgentChain, AgentIdentity, AgentKind, Did}; use sp_core::ed25519; - use sp_runtime::traits::SaturatedConversion; + use sp_runtime::traits::{SaturatedConversion, Zero}; // Trait de weights en scope para resolver `T::WeightInfo::()`. use crate::weights::WeightInfo; @@ -216,6 +327,32 @@ pub mod pallet { pub type DidToAccount = StorageMap<_, Blake2_128Concat, Did, T::AccountId, OptionQuery>; + /// Llave Ed25519 VIGENTE de cada DID. + /// + /// **Ausente ⇒ manda la llave de nacimiento**, que ya vive en + /// `Identities[cuenta].creator`. Así las identidades registradas antes de que + /// existiera la rotación no necesitan migración: para ellas la vigente y la de + /// nacimiento son la misma, y el resolutor lo sabe. + /// + /// El DID NO cambia al rotar. Nunca se re-deriva: se calculó una vez en el + /// registro y es el identificador de por vida. Lo que cambia es qué llave tiene + /// autoridad sobre él. + #[pallet::storage] + pub type CurrentKey = StorageMap<_, Blake2_128Concat, Did, [u8; 32], OptionQuery>; + + /// Registro de TODA llave que alguna vez tuvo autoridad sobre un DID. + /// + /// Una llave manda sobre un solo DID en toda la vida de la cadena. Una llave + /// retirada queda **quemada**: no puede volver ni al mismo DID ni a otro. La + /// razón es la más común para rotar —que la llave vieja se haya filtrado— y + /// dejar reutilizarla sería devolverle la autoridad a quien la robó. + #[pallet::storage] + pub type KeyToDid = StorageMap<_, Blake2_128Concat, [u8; 32], Did, OptionQuery>; + + /// Contador de rotaciones por DID. Entra en el mensaje firmado (anti-replay). + #[pallet::storage] + pub type RotationNonce = StorageMap<_, Blake2_128Concat, Did, u32, ValueQuery>; + /// Events emisión canonical (CHN-7 + Inmutable #41). #[pallet::event] #[pallet::generate_deposit(pub(super) fn deposit_event)] @@ -249,6 +386,23 @@ pub mod pallet { /// T::AccountId del agent canonical. account: T::AccountId, }, + /// La autoridad sobre un DID pasó a otra llave. El DID no cambió. + /// + /// Va AL FINAL del enum a propósito: insertarlo en medio corría el índice SCALE de + /// `AgentUpdated` de 2 a 3, y un decodificador estático generado con la metadata de + /// RV13 leería un `KeyRotated` como `AgentUpdated`. + KeyRotated { + /// Cuenta titular de la identidad. + account: T::AccountId, + /// El DID, idéntico antes y después. + did: Did, + /// Llave que tenía la autoridad y queda quemada. + old_key: [u8; 32], + /// Llave que la tiene ahora. + new_key: [u8; 32], + /// Nonce consumido por esta rotación. + nonce: u32, + }, } /// Pallet errors canonical. @@ -269,6 +423,25 @@ pub mod pallet { /// debe ser `AGENT_DID_REGISTER_DOMAIN || who.encode()`, firmado con la /// private key de `public_key`. InvalidSignature, + /// La cuenta no tiene identidad registrada: no hay nada que rotar. + NotRegistered, + /// La llave nueva es la vigente. Rotar a la misma llave no cambia nada y + /// consumiría un nonce en vano. + SameKey, + /// La llave nueva ya tuvo autoridad sobre algún DID —éste u otro, vigente o + /// retirada—, o es la llave de nacimiento de otro DID. Una llave manda sobre + /// un solo DID en toda la vida de la cadena. + KeyAlreadyUsed, + /// La firma de la llave VIGENTE no verifica: quien pide rotar no tiene la + /// autoridad actual sobre el DID. + InvalidCurrentKeySignature, + /// La firma de la llave NUEVA no verifica: no se puede ceder la autoridad a + /// una llave cuyo dueño no ha probado controlarla. + InvalidNewKeySignature, + /// La llave no es una llave con la que solo su dueño pueda firmar: no está en la + /// curva, su codificación no es canónica o es de orden pequeño. Con una llave así + /// cualquiera produce firmas válidas en nombre del DID. + WeakKey, } /// Dispatchables canonical (real impl v0.0.26). @@ -309,6 +482,13 @@ pub mod pallet { Error::::AlreadyRegistered ); + // Step 2.2: la llave tiene que ser una con la que solo su dueño firme. Sin esto + // una llave de orden pequeño supera la verificación con una firma trivial. + ensure!( + crate::is_strong_ed25519_key(&public_key), + Error::::WeakKey + ); + // Step 2.5: FIX-3 (V3-H1) DID BINDING — prueba de control de public_key. // El registrante presenta una firma Ed25519 (por la private key de // public_key) sobre AGENT_DID_REGISTER_DOMAIN || who.encode(). Cierra el @@ -333,6 +513,21 @@ pub mod pallet { Error::::DidAlreadyTaken ); + // Step 4.5: una llave manda sobre un solo DID en toda la vida de la cadena. + // Faltaba: el registro ESCRIBÍA en `KeyToDid` pero no lo CONSULTABA, así que + // quien tuviera una llave a la que otro DID ya había rotado —vigente o + // retirada— podía fundar con ella un DID nuevo y sobrescribir el índice. + // Encontrado cinco veces, por separado, en la revisión adversarial de RV14. + // + // Va DETRÁS de la colisión de DID a propósito: registrar dos veces la misma + // llave desde dos cuentas sigue devolviendo `DidAlreadyTaken`, el error + // documentado de ese caso (scenario_12). Este chequeo atrapa lo que aquel no + // ve: llaves que entraron por ROTACIÓN, cuyo `derive_did` nunca se registró. + ensure!( + !KeyToDid::::contains_key(public_key), + Error::::KeyAlreadyUsed + ); + // Step 5: build AgentChain canonical BoundedVec> let mut agent_chain = AgentChain::default(); for raw in co_authors_raw { @@ -364,6 +559,9 @@ pub mod pallet { Identities::::insert(&who, identity); DidToAccount::::insert(did, &who); + // La llave de nacimiento entra en el registro de llaves usadas desde el + // primer bloque: ninguna rotación posterior de nadie podrá apuntar a ella. + KeyToDid::::insert(public_key, did); // Step 8: emit events canonical Self::deposit_event(Event::AgentRegistered { @@ -381,6 +579,116 @@ pub mod pallet { Ok(()) } + + /// Rota la llave con autoridad sobre el DID del firmante. **El DID no cambia.** + /// + /// Existe porque sin ella una identidad no sobrevive a perder o filtrar su + /// llave: la única salida era registrarse otra vez, que genera OTRO DID, y con + /// él se pierden credenciales, créditos e historial. Es la misma ley que cerró + /// la condición #4 del DC de identidad: una identidad tiene que sobrevivir a + /// aquello en lo que se le permite convertirse. + /// + /// Exige DOS firmas sobre `agent_key_rotate_message`: + /// - la de la llave **vigente**, que autoriza ceder la autoridad; + /// - la de la llave **nueva**, que prueba que su dueño la controla. + /// + /// Sin la segunda, un titular podría «rotar» su DID a la llave pública de un + /// tercero, que pasaría a figurar como autoridad de una identidad que no es suya. + /// + /// **Hacen falta LAS DOS cosas: la cuenta titular Y la llave vigente.** Son dos + /// secretos distintos (sr25519 para firmar la extrinsic, Ed25519 para la autoridad + /// sobre el DID) y perder CUALQUIERA de los dos deja la rotación imposible: + /// - sin la llave vigente, nadie produce la primera firma; + /// - sin la cuenta, `Identities[who]` no existe para la cuenta nueva + /// (`NotRegistered`), aunque la llave esté a salvo. + /// + /// La versión anterior de este comentario decía que el único caso sin cubrir era + /// perder la llave. Era falso a medias, y la revisión adversarial lo midió. Los dos + /// casos son la RECUPERACIÓN por guardianes, que va aparte porque tiene su propio + /// ataque (robar la cuenta y retirar a los guardianes antes de que el dueño + /// reaccione). + #[pallet::call_index(1)] + #[pallet::weight(T::WeightInfo::rotate_agent_key())] + pub fn rotate_agent_key( + origin: OriginFor, + new_public_key: [u8; 32], + current_key_signature: [u8; 64], + new_key_signature: [u8; 64], + ) -> DispatchResult { + let who = ensure_signed(origin)?; + + let identity = Identities::::get(&who).ok_or(Error::::NotRegistered)?; + let did = identity.did; + let current_key = CurrentKey::::get(did).unwrap_or(identity.creator.0); + + ensure!(new_public_key != current_key, Error::::SameKey); + ensure!( + crate::is_strong_ed25519_key(&new_public_key), + Error::::WeakKey + ); + + // Quemada si alguna vez mandó sobre un DID, o si es la llave de nacimiento + // de un DID registrado (la segunda condición cubre registros que + // precedieran al índice `KeyToDid`). + ensure!( + !KeyToDid::::contains_key(new_public_key) + && !DidToAccount::::contains_key(crate::derive_did(&new_public_key)), + Error::::KeyAlreadyUsed + ); + + let nonce = RotationNonce::::get(did); + let genesis_full = >::block_hash(BlockNumberFor::::zero()); + let genesis_ref: &[u8] = genesis_full.as_ref(); + let mut genesis_hash = [0u8; 32]; + let n = genesis_ref.len().min(32); + genesis_hash[..n].copy_from_slice(&genesis_ref[..n]); + let message = crate::agent_key_rotate_message( + &genesis_hash, + did.as_bytes(), + ¤t_key, + &new_public_key, + nonce, + &who.encode(), + ); + + ensure!( + sp_io::crypto::ed25519_verify( + &ed25519::Signature::from_raw(current_key_signature), + &message, + &ed25519::Public::from_raw(current_key), + ), + Error::::InvalidCurrentKeySignature + ); + ensure!( + sp_io::crypto::ed25519_verify( + &ed25519::Signature::from_raw(new_key_signature), + &message, + &ed25519::Public::from_raw(new_public_key), + ), + Error::::InvalidNewKeySignature + ); + + CurrentKey::::insert(did, new_public_key); + // La vieja ya está en el índice en todo camino actual (el registro la escribe y + // cada rotación escribe la nueva). Solo se escribe si falta, y NUNCA se + // sobrescribe: antes era un insert incondicional que podía pisar una entrada + // que apuntaba a OTRO DID. + if !KeyToDid::::contains_key(current_key) { + KeyToDid::::insert(current_key, did); + } + KeyToDid::::insert(new_public_key, did); + RotationNonce::::insert(did, nonce.saturating_add(1)); + + Self::deposit_event(Event::KeyRotated { + account: who, + did, + old_key: current_key, + new_key: new_public_key, + nonce, + }); + + Ok(()) + } } /// Pure helper canonical para Runtime API v0.0.27 consumer. @@ -389,6 +697,16 @@ pub mod pallet { pub fn query_identity_of(account: &T::AccountId) -> Option { Identities::::get(account) } + + /// Llave Ed25519 con autoridad HOY sobre `did`: la rotada si hubo rotación, la + /// de nacimiento si no. `None` si el DID no está registrado. + pub fn current_key_of(did: &Did) -> Option<[u8; 32]> { + if let Some(k) = CurrentKey::::get(did) { + return Some(k); + } + let account = DidToAccount::::get(did)?; + Identities::::get(account).map(|id| id.creator.0) + } } } @@ -402,6 +720,22 @@ pub mod mock; #[cfg(test)] mod tests_integration; +#[cfg(test)] +mod tests_rotation; + +/// Vectores dorados de la rotación para el benchmark (que no puede firmar en no_std). +/// Los fija `tests_rotation::vectores_dorados_del_benchmark_de_rotacion`. +#[cfg(any(test, feature = "runtime-benchmarks"))] +pub mod benchmark_golden { + /// Llave pública de DAVE (seed `[0x44;32]`), destino de la rotación. + pub const ROT_DAVE_PUBKEY: &str = + "d759793bbc13a2819a827c76adb6fba8a49aee007f49f2d0992d99b825ad2c48"; + /// ALICE (vigente) firma la rotación ALICE→DAVE, génesis `0xAB×32`, nonce 0, who=`[1u8;32]`. + pub const ROT_SIG_CURRENT: &str = "4492d319dfbd4c2e4701b3b0b3e4391f269b96b8c85f3bd388c8275a8d0c5ef0c2af41d47d422db53571f2f93ad7abb9b0b2603813169223d3a0b756202afb05"; + /// DAVE (nueva) firma la misma rotación. + pub const ROT_SIG_NEW: &str = "122f3d749410f0029610c402e9096d0709f570fbd1819db057fac2d698654019ed0766c3ff08959be7a14ecf65c47fb46c93aa3361bc102dcf639975f154e60b"; +} + #[cfg(test)] mod tests { use super::*; @@ -547,4 +881,3 @@ mod tests { assert_eq!(core::mem::size_of::(), 32); } } - diff --git a/crates/origin-chain-agent-identity/src/mock.rs b/crates/origin-chain-agent-identity/src/mock.rs index d7c158e..3aa0bf2 100644 --- a/crates/origin-chain-agent-identity/src/mock.rs +++ b/crates/origin-chain-agent-identity/src/mock.rs @@ -40,10 +40,17 @@ pub fn new_test_ext() -> sp_io::TestExternalities { .into(); ext.execute_with(|| { System::set_block_number(1); + // El mensaje de rotación lleva el hash de génesis (anti-replay entre cadenas). + // Se siembra un valor fijo para que las firmas de los tests sean deterministas, + // igual que `origin-chain-provenance`. + frame_system::BlockHash::::insert(0u64, sp_core::H256(GENESIS)); }); ext } +/// Génesis de los tests de rotación. Mismo valor que usa `origin-chain-provenance`. +pub const GENESIS: [u8; 32] = [0xABu8; 32]; + /// Test origin canonical ALICE. pub fn alice_origin() -> RuntimeOrigin { RuntimeOrigin::signed(sp_runtime::AccountId32::new([1u8; 32])) @@ -103,3 +110,50 @@ pub fn sign_registration(pair: &ed25519::Pair, who: &sp_runtime::AccountId32) -> let msg = origin_chain_agent_identity::agent_did_register_message(&who.encode()); pair.sign(&msg).0 } + +// ─── Rotación de llave ──────────────────────────────────────────────────── + +/// Ed25519 pair DAVE — llave NUEVA a la que rota ALICE en los tests. +pub fn dave_pair() -> ed25519::Pair { + ed25519::Pair::from_seed(&[0x44u8; 32]) +} +/// Ed25519 pair EVE — segunda rotación / atacante. +pub fn eve_pair() -> ed25519::Pair { + ed25519::Pair::from_seed(&[0x55u8; 32]) +} + +/// Firma de rotación: `pair` firma el mensaje de rotación con EL MISMO builder que +/// el pallet. Se usa dos veces por rotación —llave vigente y llave nueva—. +pub fn sign_rotation( + pair: &ed25519::Pair, + did: &[u8; 32], + current_key: &[u8; 32], + new_key: &[u8; 32], + nonce: u32, + who: &sp_runtime::AccountId32, +) -> [u8; 64] { + sign_rotation_on(&GENESIS, pair, did, current_key, new_key, nonce, who) +} + +/// Igual que `sign_rotation` pero firmando para OTRA cadena (otro génesis). Existe para +/// probar que una rotación firmada en una cadena no vale en otra. +pub fn sign_rotation_on( + genesis: &[u8; 32], + pair: &ed25519::Pair, + did: &[u8; 32], + current_key: &[u8; 32], + new_key: &[u8; 32], + nonce: u32, + who: &sp_runtime::AccountId32, +) -> [u8; 64] { + use parity_scale_codec::Encode; + let msg = origin_chain_agent_identity::agent_key_rotate_message( + genesis, + did, + current_key, + new_key, + nonce, + &who.encode(), + ); + pair.sign(&msg).0 +} diff --git a/crates/origin-chain-agent-identity/src/tests_rotation.rs b/crates/origin-chain-agent-identity/src/tests_rotation.rs new file mode 100644 index 0000000..6b3a600 --- /dev/null +++ b/crates/origin-chain-agent-identity/src/tests_rotation.rs @@ -0,0 +1,533 @@ +//! # Rotación de llave — la identidad sobrevive a perder o filtrar su llave +//! +//! Estos tests fijan tres propiedades, en orden de gravedad: +//! +//! 1. **Tras rotar, la llave vieja pierde la autoridad.** Es la razón de existir de +//! la rotación: se rota porque la llave se filtró, y si la vieja siguiera +//! mandando, rotar no protegería de nada. +//! 2. **No se puede ceder la autoridad a una llave ajena.** Hace falta la firma de +//! la llave nueva; sin ella un titular podría poner como autoridad de su DID la +//! llave pública de un tercero. +//! 3. **El DID no cambia.** Es el identificador de por vida; lo que cambia es qué +//! llave manda sobre él. + +#![cfg(test)] + +use crate::mock::{ + alice_account, alice_origin, alice_pair, alice_pubkey, bob_account, bob_origin, bob_pair, + bob_pubkey, dave_pair, eve_pair, new_test_ext, sign_registration, sign_rotation, + sign_rotation_on, AgentIdentity as Pallet, RuntimeEvent, System, Test, GENESIS, +}; +use crate::{ + derive_did, CurrentKey, DidToAccount, Error, Event, Identities, KeyToDid, RotationNonce, +}; +use frame_support::{assert_noop, assert_ok}; +use origin_chain_types::AgentKind; +use sp_core::Pair; + +/// Registra a ALICE con su llave de nacimiento y devuelve su DID. +fn registrar_alice() -> [u8; 32] { + let sig = sign_registration(&alice_pair(), &alice_account()); + assert_ok!(Pallet::register_agent_did( + alice_origin(), + alice_pubkey(), + AgentKind::Human, + alloc::vec::Vec::new(), + sig, + )); + *derive_did(&alice_pubkey()).as_bytes() +} + +/// Rota la autoridad del DID de ALICE desde `actual` hacia `nueva`, firmando bien. +fn rotar_alice(actual: &sp_core::ed25519::Pair, nueva: &sp_core::ed25519::Pair) { + let did = *derive_did(&alice_pubkey()).as_bytes(); + let nonce = RotationNonce::::get(derive_did(&alice_pubkey())); + let (k_act, k_new) = (actual.public().0, nueva.public().0); + let s_act = sign_rotation(actual, &did, &k_act, &k_new, nonce, &alice_account()); + let s_new = sign_rotation(nueva, &did, &k_act, &k_new, nonce, &alice_account()); + assert_ok!(Pallet::rotate_agent_key( + alice_origin(), + k_new, + s_act, + s_new + )); +} + +extern crate alloc; + +#[test] +fn sin_rotacion_la_llave_vigente_es_la_de_nacimiento() { + new_test_ext().execute_with(|| { + registrar_alice(); + let did = derive_did(&alice_pubkey()); + assert_eq!(Pallet::current_key_of(&did), Some(alice_pubkey())); + // Fija lo que `creator` guarda de verdad: la llave de nacimiento, no la cuenta. + let id = Identities::::get(alice_account()).expect("registrada"); + assert_eq!(id.creator.0, alice_pubkey()); + // Y ningún CurrentKey escrito: el resolutor cae a `creator` sin migración. + assert!(CurrentKey::::get(did).is_none()); + }); +} + +#[test] +fn el_registro_quema_la_llave_de_nacimiento() { + new_test_ext().execute_with(|| { + registrar_alice(); + assert_eq!( + KeyToDid::::get(alice_pubkey()), + Some(derive_did(&alice_pubkey())) + ); + }); +} + +#[test] +fn rotar_conserva_el_did_y_cambia_la_autoridad() { + new_test_ext().execute_with(|| { + registrar_alice(); + let did = derive_did(&alice_pubkey()); + rotar_alice(&alice_pair(), &dave_pair()); + + // El DID es el mismo en los dos índices. + assert_eq!(Identities::::get(alice_account()).unwrap().did, did); + assert_eq!(DidToAccount::::get(did), Some(alice_account())); + // La autoridad es la nueva. + assert_eq!(Pallet::current_key_of(&did), Some(dave_pair().public().0)); + + System::assert_last_event(RuntimeEvent::AgentIdentity(Event::KeyRotated { + account: alice_account(), + did, + old_key: alice_pubkey(), + new_key: dave_pair().public().0, + nonce: 0, + })); + }); +} + +/// LA PROPIEDAD QUE JUSTIFICA LA ROTACIÓN. Si la llave vieja conservara la autoridad, +/// rotar tras una filtración no protegería de nada: el ladrón seguiría mandando. +#[test] +fn tras_rotar_la_llave_vieja_pierde_la_autoridad() { + new_test_ext().execute_with(|| { + registrar_alice(); + rotar_alice(&alice_pair(), &dave_pair()); + + let did = *derive_did(&alice_pubkey()).as_bytes(); + let (dave, eve) = (dave_pair().public().0, eve_pair().public().0); + + // El ladrón solo tiene la llave VIEJA (y la cuenta). Firma lo único que puede + // firmar: una rotación que declara como vigente a la vieja. + // + // ⚠️ La primera versión de este test firmaba con `current = dave`, que es algo que + // un ladrón real no conoce como suyo, y el test pasaba por la razón equivocada. Lo + // midió el crítico de la revisión adversarial: con el pallet roto para ignorar + // `CurrentKey`, esta mitad seguía verde y el ataque real devolvía `Ok`. El test + // solo se ponía rojo por su mitad POSITIVA. Ahora el ladrón firma con la vieja. + let alice = alice_pubkey(); + let robada = sign_rotation(&alice_pair(), &did, &alice, &eve, 1, &alice_account()); + let de_eve = sign_rotation(&eve_pair(), &did, &alice, &eve, 1, &alice_account()); + assert_noop!( + Pallet::rotate_agent_key(alice_origin(), eve, robada, de_eve), + Error::::InvalidCurrentKeySignature + ); + + // Y la llave vigente sí puede. + rotar_alice(&dave_pair(), &eve_pair()); + assert_eq!( + Pallet::current_key_of(&derive_did(&alice_pubkey())), + Some(eve) + ); + }); +} + +#[test] +fn sin_firma_de_la_llave_vigente_no_se_rota() { + new_test_ext().execute_with(|| { + let did = registrar_alice(); + let (a, d) = (alice_pubkey(), dave_pair().public().0); + // La "firma de la vigente" la produce otra llave. + let falsa = sign_rotation(&eve_pair(), &did, &a, &d, 0, &alice_account()); + let nueva = sign_rotation(&dave_pair(), &did, &a, &d, 0, &alice_account()); + assert_noop!( + Pallet::rotate_agent_key(alice_origin(), d, falsa, nueva), + Error::::InvalidCurrentKeySignature + ); + }); +} + +/// Sin la firma de la llave nueva, un titular podría poner como autoridad de su DID +/// la llave pública de un tercero — que figuraría como dueña de una identidad ajena. +#[test] +fn no_se_puede_ceder_la_autoridad_a_una_llave_ajena() { + new_test_ext().execute_with(|| { + let did = registrar_alice(); + let (a, e) = (alice_pubkey(), eve_pair().public().0); + let vigente = sign_rotation(&alice_pair(), &did, &a, &e, 0, &alice_account()); + // Alice "firma" también por Eve, cuya llave privada no tiene. + let suplantada = sign_rotation(&alice_pair(), &did, &a, &e, 0, &alice_account()); + assert_noop!( + Pallet::rotate_agent_key(alice_origin(), e, vigente, suplantada), + Error::::InvalidNewKeySignature + ); + }); +} + +#[test] +fn la_misma_rotacion_no_puede_reenviarse() { + new_test_ext().execute_with(|| { + let did = registrar_alice(); + let (a, d) = (alice_pubkey(), dave_pair().public().0); + let s_a = sign_rotation(&alice_pair(), &did, &a, &d, 0, &alice_account()); + let s_d = sign_rotation(&dave_pair(), &did, &a, &d, 0, &alice_account()); + assert_ok!(Pallet::rotate_agent_key(alice_origin(), d, s_a, s_d)); + // Reenviada tal cual: ahora rotar a `d` es rotar a la vigente. + assert_noop!( + Pallet::rotate_agent_key(alice_origin(), d, s_a, s_d), + Error::::SameKey + ); + }); +} + +/// Una llave retirada queda quemada para siempre. +/// +/// ⚠️ La primera versión de este test volvía a la llave de NACIMIENTO, y pasaba por +/// la razón equivocada: esa la atrapa la cláusula de `derive_did`, no el índice +/// `KeyToDid`. Lo destapó el contrafactual —se borró el chequeo de `KeyToDid` y el +/// test siguió verde—. Por eso aquí se rota a una llave retirada que NO es de +/// nacimiento de ningún DID (dave): solo el índice puede rechazarla. +#[test] +fn una_llave_retirada_queda_quemada() { + new_test_ext().execute_with(|| { + let did = registrar_alice(); + rotar_alice(&alice_pair(), &dave_pair()); // dave manda + rotar_alice(&dave_pair(), &eve_pair()); // dave queda retirada, eve manda + + let (e, d) = (eve_pair().public().0, dave_pair().public().0); + // dave no es llave de nacimiento de nadie: si se rechaza, es por el índice. + assert!(!DidToAccount::::contains_key(derive_did(&d))); + let s_e = sign_rotation(&eve_pair(), &did, &e, &d, 2, &alice_account()); + let s_d = sign_rotation(&dave_pair(), &did, &e, &d, 2, &alice_account()); + assert_noop!( + Pallet::rotate_agent_key(alice_origin(), d, s_e, s_d), + Error::::KeyAlreadyUsed + ); + }); +} + +/// La llave de nacimiento propia también está quemada una vez retirada. La atrapa +/// una cláusula distinta (`derive_did`), así que se prueba aparte. +#[test] +fn no_se_vuelve_a_la_llave_de_nacimiento_propia() { + new_test_ext().execute_with(|| { + let did = registrar_alice(); + rotar_alice(&alice_pair(), &dave_pair()); + let (d, a) = (dave_pair().public().0, alice_pubkey()); + let s_d = sign_rotation(&dave_pair(), &did, &d, &a, 1, &alice_account()); + let s_a = sign_rotation(&alice_pair(), &did, &d, &a, 1, &alice_account()); + assert_noop!( + Pallet::rotate_agent_key(alice_origin(), a, s_d, s_a), + Error::::KeyAlreadyUsed + ); + }); +} + +#[test] +fn no_se_rota_a_la_llave_de_nacimiento_de_otro_did() { + new_test_ext().execute_with(|| { + let did = registrar_alice(); + let sig_bob = sign_registration(&bob_pair(), &bob_account()); + assert_ok!(Pallet::register_agent_did( + bob_origin(), + bob_pubkey(), + AgentKind::Human, + alloc::vec::Vec::new(), + sig_bob + )); + let (a, b) = (alice_pubkey(), bob_pubkey()); + let s_a = sign_rotation(&alice_pair(), &did, &a, &b, 0, &alice_account()); + let s_b = sign_rotation(&bob_pair(), &did, &a, &b, 0, &alice_account()); + assert_noop!( + Pallet::rotate_agent_key(alice_origin(), b, s_a, s_b), + Error::::KeyAlreadyUsed + ); + }); +} + +#[test] +fn no_se_rota_a_la_misma_llave() { + new_test_ext().execute_with(|| { + let did = registrar_alice(); + let a = alice_pubkey(); + let s = sign_rotation(&alice_pair(), &did, &a, &a, 0, &alice_account()); + assert_noop!( + Pallet::rotate_agent_key(alice_origin(), a, s, s), + Error::::SameKey + ); + }); +} + +#[test] +fn sin_identidad_no_hay_nada_que_rotar() { + new_test_ext().execute_with(|| { + let (d, e) = (dave_pair().public().0, eve_pair().public().0); + let s = sign_rotation(&dave_pair(), &[0u8; 32], &d, &e, 0, &bob_account()); + assert_noop!( + Pallet::rotate_agent_key(bob_origin(), e, s, s), + Error::::NotRegistered + ); + }); +} + +/// La firma queda atada a la cuenta que la envía. Todo lo demás es correcto —mismo +/// DID, mismas llaves, mismo nonce— salvo `who`, y basta para que no verifique. +#[test] +fn la_firma_de_rotacion_esta_atada_a_la_cuenta() { + new_test_ext().execute_with(|| { + let did = registrar_alice(); + let (a, d) = (alice_pubkey(), dave_pair().public().0); + let s_a = sign_rotation(&alice_pair(), &did, &a, &d, 0, &bob_account()); + let s_d = sign_rotation(&dave_pair(), &did, &a, &d, 0, &bob_account()); + assert_noop!( + Pallet::rotate_agent_key(alice_origin(), d, s_a, s_d), + Error::::InvalidCurrentKeySignature + ); + }); +} + +#[test] +fn el_nonce_avanza_con_cada_rotacion() { + new_test_ext().execute_with(|| { + registrar_alice(); + let did = derive_did(&alice_pubkey()); + assert_eq!(RotationNonce::::get(did), 0); + rotar_alice(&alice_pair(), &dave_pair()); + assert_eq!(RotationNonce::::get(did), 1); + rotar_alice(&dave_pair(), &eve_pair()); + assert_eq!(RotationNonce::::get(did), 2); + }); +} + +/// Imprime y FIJA los vectores dorados que usa el benchmark de `rotate_agent_key`. +/// +/// El benchmark corre en `no_std` y no puede firmar, así que lleva las firmas +/// precomputadas. Este test las recalcula con el mismo builder y falla si alguien +/// cambia el mensaje de rotación sin regenerarlas: un benchmark con firmas viejas +/// mediría el camino de ERROR (la verificación falla pronto) y daría un peso falso. +#[test] +fn vectores_dorados_del_benchmark_de_rotacion() { + use crate::benchmark_golden::{ROT_DAVE_PUBKEY, ROT_SIG_CURRENT, ROT_SIG_NEW}; + let did = *derive_did(&alice_pubkey()).as_bytes(); + let (a, d) = (alice_pubkey(), dave_pair().public().0); + let s_a = sign_rotation(&alice_pair(), &did, &a, &d, 0, &alice_account()); + let s_d = sign_rotation(&dave_pair(), &did, &a, &d, 0, &alice_account()); + let hex = |b: &[u8]| { + b.iter() + .map(|x| alloc::format!("{x:02x}")) + .collect::() + }; + std::println!("ROT_DAVE_PUBKEY={}", hex(&d)); + std::println!("ROT_SIG_CURRENT={}", hex(&s_a)); + std::println!("ROT_SIG_NEW={}", hex(&s_d)); + assert_eq!(hex(&d), ROT_DAVE_PUBKEY, "pubkey de dave cambió"); + assert_eq!( + hex(&s_a), + ROT_SIG_CURRENT, + "el mensaje de rotación cambió: regenera la firma vigente" + ); + assert_eq!( + hex(&s_d), + ROT_SIG_NEW, + "el mensaje de rotación cambió: regenera la firma nueva" + ); +} + +// ─── Defectos encontrados por la revisión adversarial de RV14 ─────────────── + +/// Firma trivial `R = identidad, s = 0`. Con una llave de orden pequeño, el verificador +/// ZIP215 del host la da por buena para CUALQUIER mensaje. +fn firma_trivial() -> [u8; 64] { + let mut s = [0u8; 64]; + s[0] = 1; // R = punto identidad comprimido (01 00..00) + s +} + +fn hex32(h: &str) -> [u8; 32] { + let mut out = [0u8; 32]; + for i in 0..32 { + out[i] = u8::from_str_radix(&h[2 * i..2 * i + 2], 16).unwrap(); + } + out +} + +/// Las tres llaves débiles medidas por la sonda: identidad canónica, orden 2, e +/// identidad con codificación NO canónica (y = p + 1). +fn llaves_debiles() -> [[u8; 32]; 3] { + [ + hex32("0100000000000000000000000000000000000000000000000000000000000000"), + hex32("ecffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff7f"), + hex32("eeffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff7f"), + ] +} + +/// CONTROL POSITIVO del verificador: sin él, un `is_strong` que devolviera siempre +/// `false` haría verdes todos los tests de rechazo y bloquearía a todo el mundo. +#[test] +fn las_llaves_reales_son_fuertes_y_las_debiles_no() { + for p in [alice_pair(), bob_pair(), dave_pair(), eve_pair()] { + assert!( + crate::is_strong_ed25519_key(&p.public().0), + "llave real rechazada" + ); + } + for k in llaves_debiles() { + assert!( + !crate::is_strong_ed25519_key(&k), + "llave débil aceptada: {k:02x?}" + ); + } +} + +/// El ataque que hace grave el hallazgo: rotar a una llave de orden pequeño con una +/// firma trivial en lugar de la prueba de control. Antes daba `Ok` y dejaba el DID bajo +/// una llave con la que cualquiera firma en su nombre. +#[test] +fn no_se_rota_a_una_llave_debil() { + new_test_ext().execute_with(|| { + let did = registrar_alice(); + let a = alice_pubkey(); + for debil in llaves_debiles() { + let vigente = sign_rotation(&alice_pair(), &did, &a, &debil, 0, &alice_account()); + assert_noop!( + Pallet::rotate_agent_key(alice_origin(), debil, vigente, firma_trivial()), + Error::::WeakKey + ); + } + }); +} + +/// El registro tenía el mismo agujero: cualquiera fundaba un DID que nadie controla. +#[test] +fn no_se_registra_con_una_llave_debil() { + new_test_ext().execute_with(|| { + for debil in llaves_debiles() { + assert_noop!( + Pallet::register_agent_did( + bob_origin(), + debil, + AgentKind::Human, + alloc::vec::Vec::new(), + firma_trivial() + ), + Error::::WeakKey + ); + } + }); +} + +/// El hallazgo que salió CINCO veces: el registro escribía en `KeyToDid` pero no lo +/// consultaba. Una llave retirada de un DID fundaba otro DID y pisaba el índice. +#[test] +fn no_se_registra_con_una_llave_retirada_de_otro_did() { + new_test_ext().execute_with(|| { + registrar_alice(); + rotar_alice(&alice_pair(), &dave_pair()); + rotar_alice(&dave_pair(), &eve_pair()); // dave retirada: el caso típico de una filtrada + let d = dave_pair().public().0; + let sig = sign_registration(&dave_pair(), &bob_account()); + assert_noop!( + Pallet::register_agent_did( + bob_origin(), + d, + AgentKind::Human, + alloc::vec::Vec::new(), + sig + ), + Error::::KeyAlreadyUsed + ); + // Y el índice sigue apuntando al DID de alice. + assert_eq!(KeyToDid::::get(d), Some(derive_did(&alice_pubkey()))); + }); +} + +#[test] +fn no_se_registra_con_la_llave_vigente_de_otro_did() { + new_test_ext().execute_with(|| { + registrar_alice(); + rotar_alice(&alice_pair(), &dave_pair()); // dave manda sobre el DID de alice + let d = dave_pair().public().0; + let sig = sign_registration(&dave_pair(), &bob_account()); + assert_noop!( + Pallet::register_agent_did( + bob_origin(), + d, + AgentKind::Human, + alloc::vec::Vec::new(), + sig + ), + Error::::KeyAlreadyUsed + ); + }); +} + +/// Hueco medido por el crítico: borrando `KeyToDid::insert(new_public_key, …)` pasaban +/// los 38 tests. Faltaba el caso «rotar a la llave vigente, NO de nacimiento, de otro DID». +#[test] +fn no_se_rota_a_la_llave_vigente_no_natal_de_otro_did() { + new_test_ext().execute_with(|| { + registrar_alice(); + rotar_alice(&alice_pair(), &dave_pair()); // dave: vigente de alice, no natal de nadie + let sig_bob = sign_registration(&bob_pair(), &bob_account()); + assert_ok!(Pallet::register_agent_did( + bob_origin(), + bob_pubkey(), + AgentKind::Human, + alloc::vec::Vec::new(), + sig_bob + )); + let did_bob = *derive_did(&bob_pubkey()).as_bytes(); + let (b, d) = (bob_pubkey(), dave_pair().public().0); + assert!( + !DidToAccount::::contains_key(derive_did(&d)), + "dave no es natal" + ); + let s_b = sign_rotation(&bob_pair(), &did_bob, &b, &d, 0, &bob_account()); + let s_d = sign_rotation(&dave_pair(), &did_bob, &b, &d, 0, &bob_account()); + assert_noop!( + Pallet::rotate_agent_key(bob_origin(), d, s_b, s_d), + Error::::KeyAlreadyUsed + ); + }); +} + +/// Replay entre cadenas: una rotación firmada con OTRO génesis no vale aquí. Todo lo +/// demás es idéntico —cuenta, DID, llaves, nonce—. +#[test] +fn una_rotacion_firmada_para_otra_cadena_no_vale() { + new_test_ext().execute_with(|| { + let did = registrar_alice(); + let otra_cadena = [0xCDu8; 32]; + assert_ne!(otra_cadena, GENESIS); + let (a, d) = (alice_pubkey(), dave_pair().public().0); + let s_a = sign_rotation_on( + &otra_cadena, + &alice_pair(), + &did, + &a, + &d, + 0, + &alice_account(), + ); + let s_d = sign_rotation_on( + &otra_cadena, + &dave_pair(), + &did, + &a, + &d, + 0, + &alice_account(), + ); + assert_noop!( + Pallet::rotate_agent_key(alice_origin(), d, s_a, s_d), + Error::::InvalidCurrentKeySignature + ); + }); +} diff --git a/crates/origin-chain-agent-identity/src/weights.rs b/crates/origin-chain-agent-identity/src/weights.rs index 67aaa56..2ae012e 100644 --- a/crates/origin-chain-agent-identity/src/weights.rs +++ b/crates/origin-chain-agent-identity/src/weights.rs @@ -14,7 +14,7 @@ //! procedimiento completo y el hardware al que hay que creerle). //! //! GENERADO POR SUBSTRATE BENCHMARK CLI VERSION 51.0.0 -//! FECHA: 2026-09-09, STEPS: `50`, REPEAT: `20` +//! FECHA: 2026-09-16, STEPS: `50`, REPEAT: `20` //! HOSTNAME: `MacBookProM3Max.local`, CPU: `` //! WASM-EXECUTION: `Compiled`, DB CACHE: 1024 @@ -24,7 +24,7 @@ // benchmark // pallet // --runtime -// /Users/davidherran/originmetaverse/artifacts/origin-chain/origin_chain_runtime_rv12_bench.compact.compressed.wasm +// target/release/wbuild/origin-chain-runtime/origin_chain_runtime.compact.compressed.wasm // --genesis-builder-preset // development // --pallet @@ -36,11 +36,11 @@ // --repeat // 20 // --template -// /Users/davidherran/originmetaverse/external/arkhe-origin-chain/scripts/bench/origin-weights-template.hbs +// scripts/bench/origin-weights-template.hbs // --header -// /Users/davidherran/originmetaverse/external/arkhe-origin-chain/scripts/bench/HEADER.txt +// scripts/bench/HEADER.txt // --output -// /Users/davidherran/originmetaverse/external/arkhe-origin-chain/crates/origin-chain-agent-identity/src/weights.rs +// crates/origin-chain-agent-identity/src/weights.rs #![cfg_attr(rustfmt, rustfmt_skip)] #![allow(unused_parens)] @@ -54,6 +54,8 @@ use frame_support::{traits::Get, weights::constants::RocksDbWeight, weights::Wei pub trait WeightInfo { /// Peso de `register_agent_did` — MEDIDO sobre el WASM, no estimado a mano. fn register_agent_did() -> Weight; + /// Peso de `rotate_agent_key` — MEDIDO sobre el WASM, no estimado a mano. + fn rotate_agent_key() -> Weight; } /// Weights parametrizados por el `DbWeight` real del runtime. @@ -63,6 +65,8 @@ impl WeightInfo for SubstrateWeight { /// Proof: `AgentIdentity::Identities` (`max_values`: None, `max_size`: Some(475), added: 2950, mode: `MaxEncodedLen`) /// Storage: `AgentIdentity::DidToAccount` (r:1 w:1) /// Proof: `AgentIdentity::DidToAccount` (`max_values`: None, `max_size`: Some(80), added: 2555, mode: `MaxEncodedLen`) + /// Storage: `AgentIdentity::KeyToDid` (r:1 w:1) + /// Proof: `AgentIdentity::KeyToDid` (`max_values`: None, `max_size`: Some(80), added: 2555, mode: `MaxEncodedLen`) /// Storage: `System::Number` (r:1 w:0) /// Proof: `System::Number` (`max_values`: Some(1), `max_size`: Some(4), added: 499, mode: `MaxEncodedLen`) /// Storage: `System::ExecutionPhase` (r:1 w:0) @@ -75,21 +79,57 @@ impl WeightInfo for SubstrateWeight { // Proof size en bytes: // Medido: `25` // Estimado: `3940` - // Tiempo minimo de ejecucion: 41_000_000 picosegundos. - Weight::from_parts(43_000_000, 0) + // Tiempo minimo de ejecucion: 54_000_000 picosegundos. + Weight::from_parts(55_000_000, 0) .saturating_add(Weight::from_parts(0, 3940)) - .saturating_add(T::DbWeight::get().reads(6_u64)) - .saturating_add(T::DbWeight::get().writes(4_u64)) + .saturating_add(T::DbWeight::get().reads(7_u64)) + .saturating_add(T::DbWeight::get().writes(5_u64)) + } + /// Storage: `AgentIdentity::Identities` (r:1 w:0) + /// Proof: `AgentIdentity::Identities` (`max_values`: None, `max_size`: Some(475), added: 2950, mode: `MaxEncodedLen`) + /// Storage: `AgentIdentity::CurrentKey` (r:1 w:1) + /// Proof: `AgentIdentity::CurrentKey` (`max_values`: None, `max_size`: Some(80), added: 2555, mode: `MaxEncodedLen`) + /// Storage: `AgentIdentity::KeyToDid` (r:2 w:1) + /// Proof: `AgentIdentity::KeyToDid` (`max_values`: None, `max_size`: Some(80), added: 2555, mode: `MaxEncodedLen`) + /// Storage: `AgentIdentity::DidToAccount` (r:1 w:0) + /// Proof: `AgentIdentity::DidToAccount` (`max_values`: None, `max_size`: Some(80), added: 2555, mode: `MaxEncodedLen`) + /// Storage: `AgentIdentity::RotationNonce` (r:1 w:1) + /// Proof: `AgentIdentity::RotationNonce` (`max_values`: None, `max_size`: Some(52), added: 2527, mode: `MaxEncodedLen`) + /// Storage: `System::BlockHash` (r:1 w:0) + /// Proof: `System::BlockHash` (`max_values`: None, `max_size`: Some(44), added: 2519, mode: `MaxEncodedLen`) + /// Storage: `System::Number` (r:1 w:0) + /// Proof: `System::Number` (`max_values`: Some(1), `max_size`: Some(4), added: 499, mode: `MaxEncodedLen`) + /// Storage: `System::ExecutionPhase` (r:1 w:0) + /// Proof: `System::ExecutionPhase` (`max_values`: Some(1), `max_size`: Some(5), added: 500, mode: `MaxEncodedLen`) + /// Storage: `System::EventCount` (r:1 w:1) + /// Proof: `System::EventCount` (`max_values`: Some(1), `max_size`: Some(4), added: 499, mode: `MaxEncodedLen`) + /// Storage: `System::Events` (r:1 w:1) + /// Proof: `System::Events` (`max_values`: Some(1), `max_size`: None, mode: `Measured`) + fn rotate_agent_key() -> Weight { + // Proof size en bytes: + // Medido: `434` + // Estimado: `6100` + // Tiempo minimo de ejecucion: 93_000_000 picosegundos. + Weight::from_parts(97_000_000, 0) + .saturating_add(Weight::from_parts(0, 6100)) + .saturating_add(T::DbWeight::get().reads(11_u64)) + .saturating_add(T::DbWeight::get().writes(5_u64)) } } /// Impl para mocks y tests — `RocksDbWeight` directo, sin necesitar `T`. impl WeightInfo for () { fn register_agent_did() -> Weight { - Weight::from_parts(43_000_000, 0) + Weight::from_parts(55_000_000, 0) .saturating_add(Weight::from_parts(0, 3940)) - .saturating_add(RocksDbWeight::get().reads(6_u64)) - .saturating_add(RocksDbWeight::get().writes(4_u64)) + .saturating_add(RocksDbWeight::get().reads(7_u64)) + .saturating_add(RocksDbWeight::get().writes(5_u64)) + } + fn rotate_agent_key() -> Weight { + Weight::from_parts(97_000_000, 0) + .saturating_add(Weight::from_parts(0, 6100)) + .saturating_add(RocksDbWeight::get().reads(11_u64)) + .saturating_add(RocksDbWeight::get().writes(5_u64)) } } @@ -119,6 +159,7 @@ mod guard { fn ningun_weight_declara_proof_size_cero() { let casos: &[(&str, Weight)] = &[ ("register_agent_did", <() as WeightInfo>::register_agent_did()), + ("rotate_agent_key", <() as WeightInfo>::rotate_agent_key()), ]; for (nombre, w) in casos { assert!( diff --git a/crates/origin-chain-node/src/main.rs b/crates/origin-chain-node/src/main.rs index 98522db..5d666f6 100644 --- a/crates/origin-chain-node/src/main.rs +++ b/crates/origin-chain-node/src/main.rs @@ -224,7 +224,7 @@ fn print_info() { println!(" • Index 9 AgentIdentity (pallet_agent_identity AGI DIDs Inmutable #41) [Phase 2.7 COMPLETE v0.0.27]"); println!(" • Index 10 Governance (origin_chain_governance Treasury+Council+Advisors+Emergency+Disbursement CHN-4) [Phase 5 FULLY COMPLETE v0.0.34]"); println!(); - println!("Runtime APIs canonical (15):"); + println!("Runtime APIs canonical (16):"); println!(" • sp_api::Core (version + execute_block)"); println!(" • sp_api::Metadata (runtime metadata)"); println!(" • sp_block_builder::BlockBuilder (block production)"); @@ -239,6 +239,7 @@ fn print_info() { println!(" • origin_chain_provenance::ProvenanceApi (verify_provenance RPC) [Phase 2.1 v0.0.17]"); println!(" • origin_chain_fees::FeesApi (query_fee_for_action RPC) [Phase 2.4 COMPLETE v0.0.23]"); println!(" • origin_chain_agent_identity::AgentIdentityApi (query_identity RPC) [Phase 2.7 COMPLETE v0.0.27]"); + println!(" • origin_chain_agent_identity::AgentKeyApi (current_key RPC: DID → llave vigente) [RV14]"); println!(" • origin_chain_governance::GovernanceApi (query_proposal + Council + Advisor + Treasury RPCs) [Phase 5 FULLY COMPLETE v0.0.34]"); println!(); println!("Genesis presets canonical (3):"); diff --git a/crates/origin-chain-runtime/src/configs.rs b/crates/origin-chain-runtime/src/configs.rs index cc86e38..1b5d7e1 100644 --- a/crates/origin-chain-runtime/src/configs.rs +++ b/crates/origin-chain-runtime/src/configs.rs @@ -99,6 +99,19 @@ impl frame_system::Config for Runtime { type AccountData = pallet_balances::AccountData; type SS58Prefix = SS58Prefix; type MaxConsumers = ConstU32<16>; + /// **El coste de leer y escribir storage.** Faltaba, y por eso valía CERO. + /// + /// `SolochainDefaultConfig` trae `type DbWeight = ();`, y este `impl` lo heredaba sin + /// declararlo: `RuntimeDbWeight { read: 0, write: 0 }`. Todo `.reads(n)` y + /// `.writes(n)` de los `weights.rs` de la cadena se multiplicaba por cero, así que la + /// E/S de storage no se cobraba en fees ni contaba para llenar el bloque. Lo midió la + /// revisión adversarial de RV14 con una sonda sobre el runtime real. + /// + /// ⚠️ Esto corrige también lo que RV13 afirmaba haber arreglado. RV13 midió los 19 + /// extrinsics y subió sus reads/writes, pero con `DbWeight = ()` esa parte no tuvo + /// ningún efecto en el runtime. Lo que RV13 sí arregló de verdad es el `proof_size` + /// y el `ref_time` de ejecución, que no pasan por `DbWeight`. + type DbWeight = frame_support::weights::constants::RocksDbWeight; } // ============================================================ diff --git a/crates/origin-chain-runtime/src/lib.rs b/crates/origin-chain-runtime/src/lib.rs index d4fee20..bdf7403 100644 --- a/crates/origin-chain-runtime/src/lib.rs +++ b/crates/origin-chain-runtime/src/lib.rs @@ -340,6 +340,33 @@ pub const IMPL_NAME: &str = borrowed_str(&VERSION.impl_name); /// correr `scripts/bench/regen-weights.sh` en el Pi 5 con governor y aislamiento /// declarados (peticion abierta a Laurha, `request:66f500d54e28a80c`). /// TRANSACTION_VERSION intacto: ninguna firma de extrinsic cambia. +/// +/// **v14 (una identidad sobrevive a perder su llave)**: nace `rotate_agent_key` +/// (call_index 1 de `origin-chain-agent-identity`) y la runtime API `AgentKeyApi`. +/// Hasta hoy el DID quedaba atado de hecho a su llave de nacimiento: no se +/// re-derivaba, pero tampoco había forma de pasar la autoridad a otra llave, así que +/// perder o filtrar la llave obligaba a registrarse otra vez con OTRO DID y perder +/// credenciales, créditos e historial. Ahora el DID es el mismo de por vida y lo que +/// rota es qué llave manda sobre él. Exige dos firmas —la vigente autoriza, la nueva +/// prueba control— y una llave retirada queda quemada para siempre. Es la +/// dependencia de dos hilos: el DID del niño (Marihan) y los créditos de referido +/// indexados por DID (Codex). Nuevo storage (`CurrentKey`, `KeyToDid`, +/// `RotationNonce`) + call aditiva en índice nuevo ⇒ spec bump; TRANSACTION_VERSION +/// intacto. Sin migración: una identidad sin entrada en `CurrentKey` resuelve a su +/// llave de nacimiento, que ya vivía en `creator`. +/// +/// **Y lo que la revisión adversarial encontró antes de publicar** (5 lentes, 18 +/// hallazgos, 17 confirmados, cada arreglo con su contrafactual visto en rojo): +/// - **`DbWeight = ()` en todo el runtime** — heredado sin declarar de +/// `SolochainDefaultConfig`. La E/S de storage valía CERO en fees y en llenado de +/// bloque para TODOS los extrinsics. Ahora `RocksDbWeight`. Esto invalida en parte lo +/// que dice la nota de v13: los reads/writes que midió no tenían efecto en el runtime; +/// el `proof_size` y el `ref_time` de ejecución sí. +/// - **Llaves de orden pequeño**: el verificador del host (ZIP215) las acepta, y con la +/// firma trivial `R = identidad, s = 0` la «prueba de control» se superaba sin llave +/// privada, en rotación Y en registro. Ahora `is_strong_ed25519_key` las rechaza. +/// - **El registro no consultaba `KeyToDid`**: una llave rotada fundaba otro DID. +/// - **Replay entre cadenas**: el mensaje de rotación no llevaba génesis. Ahora sí. pub const RUNTIME_VERSION: u32 = VERSION.spec_version; /// Canonical authoring version per Substrate semver canonical. @@ -783,6 +810,12 @@ impl_runtime_apis! { // // Phase 2.7 COMPLETE canonical post-v0.0.27 🎉 + impl origin_chain_agent_identity::AgentKeyApi for Runtime { + fn current_key(did: origin_chain_types::Did) -> Option<[u8; 32]> { + AgentIdentity::current_key_of(&did) + } + } + impl origin_chain_agent_identity::AgentIdentityApi for Runtime { fn query_identity( account: origin_chain_types::AccountId, @@ -986,7 +1019,7 @@ pub const VERSION: sp_version::RuntimeVersion = sp_version::RuntimeVersion { spec_name: alloc::borrow::Cow::Borrowed("origin-chain"), impl_name: alloc::borrow::Cow::Borrowed("origin-chain-charon"), authoring_version: 1, - spec_version: 13, + spec_version: 14, impl_version: 1, apis: RUNTIME_API_VERSIONS, transaction_version: 1, @@ -1009,6 +1042,38 @@ use sp_api::impl_runtime_apis; mod tests { use super::*; + /// El storage cuesta: leer y escribir no puede ser gratis en el runtime. + /// + /// Nace de un defecto que sobrevivió a toda la vida de la cadena: `frame_system` + /// heredaba `DbWeight = ()` y cada `.reads(n)`/`.writes(n)` de los pesos valía cero. + /// Las 19 mediciones de RV13 subieron esos números sin cambiar nada en la práctica. + #[test] + fn leer_y_escribir_storage_no_es_gratis() { + use frame_support::traits::Get; + // Tipo anotado a propósito: con `DbWeight = ()` la llamada sin anotar ni compila + // (ambigüedad de `Get`), y un test que solo reprueba por un error de inferencia + // protege por accidente. Anotado, compila en los dos casos y reprueba la ASERCIÓN. + let db: frame_support::weights::RuntimeDbWeight = + ::DbWeight::get(); + assert!( + db.read > 0, + "DbWeight.read = 0: las lecturas de storage salen gratis" + ); + assert!( + db.write > 0, + "DbWeight.write = 0: las escrituras de storage salen gratis" + ); + // Y llega de verdad a un peso real de la cadena. + let rot = + as origin_chain_agent_identity::weights::WeightInfo>::rotate_agent_key(); + let base = <() as origin_chain_agent_identity::weights::WeightInfo>::rotate_agent_key(); + assert_eq!( + rot.ref_time(), + base.ref_time(), + "el runtime debe usar RocksDbWeight, igual que el impl ()" + ); + } + /// Fija la version vigente y la invariante que la acompaña. /// /// El test NO cuenta la historia de cada version — eso vive en el doc comment @@ -1022,7 +1087,7 @@ mod tests { /// mismo commit en que la version subio a 12. #[test] fn spec_version_vigente_y_transaction_version_intacta() { - assert_eq!(RUNTIME_VERSION, 13); + assert_eq!(RUNTIME_VERSION, 14); assert_eq!(TRANSACTION_VERSION, 1); } diff --git a/crates/origin-chain-types/src/identity.rs b/crates/origin-chain-types/src/identity.rs index 578d4f4..0791563 100644 --- a/crates/origin-chain-types/src/identity.rs +++ b/crates/origin-chain-types/src/identity.rs @@ -28,7 +28,7 @@ //! //! 1. `did`: BLAKE3 32-byte hash canonical (deterministic via public_key derivation) //! 2. `kind`: AgentKind enum Human/AGI/Hybrid canonical -//! 3. `creator`: AccountId32 que registró el identity canonical +//! 3. `creator`: la llave Ed25519 de NACIMIENTO (no la cuenta firmante; ver el campo) //! 4. `registered_at_block`: u64 block number canonical sealed by runtime //! 5. `provenance_proof`: Option linking to chain attestation canonical //! 6. `agent_chain`: BoundedVec> co-authorship chain @@ -50,6 +50,21 @@ use crate::{AccountId, AgentChain, AssetHash}; /// cross-runtime garantizado (Pi 5 · Desktop · M4 Ultra ⇒ mismo DID, Inmutable /// #16 byte-determinism). /// +/// # ⚠️ Desde RV14 esto vale para la llave de NACIMIENTO, no para la vigente +/// +/// La rotación de llave (`rotate_agent_key`) cambia qué llave manda sobre el DID y +/// **deja el DID igual**, que es su razón de ser: una identidad no puede morir por +/// perder o filtrar su llave. Consecuencia que hay que tener clara, y que midió la +/// revisión adversarial de RV14: tras rotar, **`derive(llave_vigente) != did`**. +/// +/// Por eso la promesa del DC de unificación se cumple completa solo para identidades +/// que nunca rotaron. Para cualquier llave, la forma correcta de obtener el DID es +/// RESOLVERLO contra la cadena (`KeyToDid[llave]`), no derivarlo. Una capa que siga +/// derivando —OCF con `OitFingerprint::from_ed25519_pubkey`, Palace con su wallet— +/// verá a un usuario que rotó como una identidad nueva y vacía. Eso es una enmienda +/// pendiente del DC, con las firmantes de la unificación, y no se resuelve en este +/// repo. +/// /// # Por qué es un NEWTYPE y no un alias de [`AssetHash`] /// /// Condición vinculante **#3 de Mathi** en la firma que ratificó el DC: *"un @@ -180,8 +195,19 @@ pub struct AgentIdentity { pub did: Did, /// Agent kind canonical (Human + AGI + Hybrid per Inmutable #41). pub kind: AgentKind, - /// AccountId que registró el identity on-chain canonical. Para Human + - /// AGI = self-registration. Para Hybrid = canonical Human que coordinates. + /// **La llave pública Ed25519 de NACIMIENTO** de esta identidad, guardada en un + /// `AccountId` porque ambos son 32 bytes. + /// + /// ⚠️ El nombre engaña y el comentario anterior también: decía que era «el + /// AccountId que registró», pero `register_agent_did` guarda aquí + /// `AccountId::new(public_key)` — la llave Ed25519 de la que se derivó el DID, no + /// la cuenta firmante. Corregido el 2026-09-16 al construir la rotación, que + /// depende exactamente de esto: si no ha habido rotación, ésta es la llave con + /// autoridad sobre el DID. + /// + /// **Para verificar una firma del titular NO uses este campo**: usa la llave + /// VIGENTE (`AgentKeyApi::current_key`). Tras una rotación son distintas, y este + /// campo nunca cambia. pub creator: AccountId, /// Block number canonical at which identity was registered (sealed by runtime). pub registered_at_block: u64,