From ce18e191d0e846fb075b0b0d672e6d093c74d6cc Mon Sep 17 00:00:00 2001 From: DavidHerran Date: Thu, 11 Jun 2026 19:52:57 -0500 Subject: [PATCH] =?UTF-8?q?feat(provenance):=20FIX-4=20=E2=80=94=20verific?= =?UTF-8?q?ador=20attestation=20on-chain=20(cierra=20V3-C1=20+=20squat)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit El verificador attestation queda VIVO: una firma producida por el código REAL de Ergon (arkhe-noesis-core, postcard + mensaje domain-separado) verifica byte-fiel en el pallet Substrate. Cierra el FIX-DEFERRED del roadmap (helper de Ergon ya en árbol) + el replay cross-chain V3-C1. origin-chain-types/attestation_verify.rs (nuevo): - ATTESTATION_DOMAIN_TAG + attestation_signed_message: espejo BYTE-IDÉNTICO del oráculo (DC 076d15cc ASP-3). Mensaje = TAG ‖ genesis_hash(32) ‖ payload. - decode_signing_payload: lector postcard SECUENCIAL total y estricto del SigningPayload de Ergon (sin dep nueva en el runtime wasm frágil). Lectores varint u64/u32 con los bounds exactos de postcard (cierra el LOW de fidelidad flagged: verdict/severity/constraints son u32, máx 5 bytes / 5º ≤ 15). Cotas DoS en strings. NUNCA re-serializa (contrato ASP-3). pallet-origin-provenance: - attest_asset(signing_payload: BoundedVec, signature: [u8;64]): recibe los bytes postcard OPACOS + la firma; decodifica, verifica Ed25519 vía sp_io::crypto::ed25519_verify (host function, no bloatea wasm, precedente FIX-3) sobre el mensaje domain-separado con el genesis_hash de ESTA cadena. La pubkey se decodifica del payload (no param aparte). creator = el validador, anchored_at_block real (cerró los placeholders del scaffold). - Dedup-by-BLAKE3 con rechazo EXPLÍCITO (asset_id = BLAKE3 content hash, dentro del payload firmado → replay del mismo blob reusa el asset_id → AlreadyAttested). Hallazgos de la verificación adversarial (workflow ultracode, 3 lentes) cerrados de RAÍZ: - HIGH asset-id squat / provenance poisoning + MEDIUM tier-forgery: el gate era self-attested (level del payload) → cualquiera con una clave Ed25519 anclaba basura institucional bajo el hash PÚBLICO de un asset legítimo y lo envenenaba de forma inmutable. FIX: registro on-chain AuthorizedValidators (pubkey → nivel OIT) gobernado por Root (register_validator/revoke_validator); la membresía es la VERDAD, no el payload. Mismo patrón que TierProfiles de FIX-6 Stage 2a (legitimidad on-chain, no caller-supplied). Sunset a attestation-driven cuando aterrice el registro OIT de Mathi. - MEDIUM método muerto: removido el ValidationAttestation::signing_payload() SCALE divergente (producía bytes que jamás verificarían — footgun). Decisiones (autoridad Pilar 4): sp_io ed25519_verify (consistente FIX-3, estándar Substrate) · dedup-by-asset_id subsume replay exacto · golden vectors generados desde el oráculo REAL de Ergon (seed [7u8;32], genesis 0xAB) = prueba cross-runtime. PENDIENTE (gap legítimo, no bug): OriginChainAttestationSink (impl del trait ValidationAttestationSink de Ergon) es código cliente off-chain — vive en un crate bridge del monorepo (subxt), NO en el chain repo (dependency direction submódulo→monorepo prohibida). Se cablea con la integración Noesis→chain. Tests: 19 provenance (golden vectors cross-runtime + squat cerrado + revoke + anti-replay cross/intra-chain + tamper + malformed) + 72 types (decoder fuzz- equivalente a postcard + fidelidad u32) + 21 suites workspace verde. cargo +1.88. WASM: build VPS-gated (UnknownOpcode 252 ambiental, ver v0.0.58). Co-Authored-By: Claude Fable 5 --- crates/origin-chain-provenance/Cargo.toml | 17 +- crates/origin-chain-provenance/src/lib.rs | 310 +++++++------ crates/origin-chain-provenance/src/mock.rs | 71 +-- .../src/tests_integration.rs | 413 ++++++++---------- crates/origin-chain-types/src/attestation.rs | 48 +- .../src/attestation_verify.rs | 378 ++++++++++++++++ crates/origin-chain-types/src/lib.rs | 7 + 7 files changed, 769 insertions(+), 475 deletions(-) create mode 100644 crates/origin-chain-types/src/attestation_verify.rs diff --git a/crates/origin-chain-provenance/Cargo.toml b/crates/origin-chain-provenance/Cargo.toml index f6b79b1..3a81539 100644 --- a/crates/origin-chain-provenance/Cargo.toml +++ b/crates/origin-chain-provenance/Cargo.toml @@ -20,21 +20,26 @@ frame-system = { workspace = true } # Runtime API canonical (v0.0.17 PROV-8 — verify_provenance read-only RPC query) sp-api = { workspace = true } -# Crypto canonical +# Crypto canonical. FIX-4: sp-io host function ed25519_verify (no bloatea wasm, +# precedente FIX-3 agent-identity) + blake3 para dedup. ed25519-dalek se mantiene +# solo para los fixtures de test (dev-deps) — el runtime usa sp_io. blake3 = { workspace = true } -ed25519-dalek = { workspace = true } + +# FIX-4 (V3-C1): verificacion Ed25519 on-chain via host function + genesis read. +sp-core = { workspace = true } +sp-io = { workspace = true } # SCALE codec canonical parity-scale-codec = { workspace = true } scale-info = { workspace = true } [dev-dependencies] -# Mock runtime canonical para integration tests v0.0.18 — PROV-3 E2E flow. -# Per pallet Substrate convention: sp-io TestExternalities + sp-runtime -# AccountId32 + frame_support construct_runtime macro. +# Mock runtime canonical para integration tests — PROV-3 E2E flow + golden +# vectors ASP-3. ed25519-dalek solo aqui (fixtures); el runtime usa sp_io. sp-core = { workspace = true, default-features = true } sp-io = { workspace = true, default-features = true } sp-runtime = { workspace = true, default-features = true } +ed25519-dalek = { workspace = true, default-features = true } [features] default = ["std"] @@ -43,6 +48,8 @@ std = [ "frame-support/std", "frame-system/std", "sp-api/std", + "sp-core/std", + "sp-io/std", "parity-scale-codec/std", "scale-info/std", ] diff --git a/crates/origin-chain-provenance/src/lib.rs b/crates/origin-chain-provenance/src/lib.rs index f36cf13..37d9e26 100644 --- a/crates/origin-chain-provenance/src/lib.rs +++ b/crates/origin-chain-provenance/src/lib.rs @@ -103,7 +103,14 @@ sp_api::decl_runtime_apis! { pub mod pallet { use frame_support::pallet_prelude::*; use frame_system::pallet_prelude::*; - use origin_chain_types::{AssetAttestation, AssetHash, ValidationAttestation}; + use frame_support::sp_runtime::traits::{SaturatedConversion, Zero}; + use origin_chain_types::{ + attestation_signed_message, decode_signing_payload, AssetAttestation, AssetHash, + }; + + /// Cota DoS del `signing_payload` postcard recibido (el real ~115 bytes; + /// 512 es holgado para schema/identifier futuros sin permitir flooding). + pub type MaxPayloadLen = ConstU32<512>; /// Pallet configuration trait canonical. /// @@ -140,6 +147,25 @@ pub mod pallet { pub type Attestations = StorageMap<_, Blake2_128Concat, AssetHash, AssetAttestation, OptionQuery>; + /// FIX-4 (cierra el HIGH "asset-id squat"): registro on-chain de validadores + /// Noesis autorizados → nivel OIT asignado por governance. **La membresía es + /// la VERDAD**, no el `level` que el firmante pone en su propio payload + /// (que era self-attested → cualquiera con una clave Ed25519 podía anclar + /// basura institucional bajo el hash público de un asset legítimo y + /// envenenarlo de forma inmutable). Ahora solo una pubkey REGISTRADA puede + /// anclar; un validador registrado que ancle basura es revocable y auditable + /// (problema de governance, no ataque anónimo). Mismo patrón que + /// `TierProfiles` de FIX-6 Stage 2a: legitimidad on-chain, no caller-supplied. + /// + /// **Root-only interim** (sudo Index 12 / governance). Sunset: cuando el + /// registro OIT real de Mathi (PerceptionAdapter + DID binding) aterrice, + /// migra a auto-registro attestation-driven. Cross-link futuro: AgentIdentity + /// (Index 9). + #[pallet::storage] + #[pallet::getter(fn authorized_validator_level)] + pub type AuthorizedValidators = + StorageMap<_, Blake2_128Concat, [u8; 32], u8, OptionQuery>; + /// PROV-7 canonical: Events emisión canonical. #[pallet::event] #[pallet::generate_deposit(pub(super) fn deposit_event)] @@ -158,6 +184,19 @@ pub mod pallet { /// Reason canonical de rejection per RejectionReason enum. reason: RejectionReason, }, + /// FIX-4: validador Noesis autorizado (Root) con su nivel OIT. Auditable + /// on-chain: quién quedó habilitado para anclar y a qué nivel. + ValidatorRegistered { + /// Ed25519 public key del validador autorizado. + public_key: [u8; 32], + /// Nivel OIT asignado por governance. + level: u8, + }, + /// FIX-4: validador revocado (Root) — ya no puede anclar. + ValidatorRevoked { + /// Ed25519 public key del validador revocado. + public_key: [u8; 32], + }, } /// Rejection reasons canonical PROV-7. @@ -204,102 +243,138 @@ pub mod pallet { AlreadyAttested, /// Marihan refinement Phase 2.2 noted canonical. MalformedValidationAttestation, + /// FIX-4: la pubkey del firmante NO está en el registro on-chain de + /// validadores autorizados (cierra el squat — solo validadores + /// registrados por governance pueden anclar). + ValidatorNotAuthorized, } - /// Dispatchables canonical (PROV-3 canonical: attest_asset). + /// Dispatchables (PROV-3: attest_asset). #[pallet::call] impl Pallet { - /// PROV-3 canonical: `attest_asset` extrinsic canonical. + /// PROV-3 + FIX-4: ancla una attestation Noesis verificada on-chain. /// - /// Submits a `ValidationAttestation` (Ergon Slice 7.0r struct) para - /// anchoring canonical on-chain. Flow canonical 6-step per DC PROV-3: + /// El validador firma OFF-CHAIN (con el helper de Ergon + /// `attestation_signed_message`) sobre `TAG ‖ genesis_hash ‖ payload`, + /// donde `payload` es el `SigningPayload` postcard (Slice 7.0r). El + /// extrinsic recibe esos **bytes postcard opacos** + la firma; el pallet + /// re-deriva el mensaje y verifica — NUNCA re-serializa (contrato ASP-3 + /// DC `076d15cc`). El `public_key` se DECODIFICA del payload (no es un + /// parámetro aparte: la firma prueba que esa clave firmó esta attestation + /// para esta cadena). /// - /// 1. PROV-4 canonical: Ed25519 signature verification canonical - /// 2. PROV-5 canonical: validate `validator_oit.level >= 3` institutional - /// 3. PROV-6 canonical: check `is_anchorable()` (verdict + severity) - /// 4. PROV-2 canonical: convert ValidationAttestation → AssetAttestation - /// 5. PROV-2 canonical: insert StorageMap canonical - /// 6. PROV-7 canonical: emit AssetAttested event canonical - /// - /// **SCAFFOLD v0.0.14 canonical**: real impl deferred post Ergon Cap 42+ - /// sub-crate ship + Ed25519 verification wire-up. Current canonical solo - /// validates basic state (origin signed + not already attested) + emite - /// placeholder event canonical. + /// Flow (gates baratos antes del crypto caro, patrón FIX-3): + /// 1. `ensure_signed` — el submitter (relayer/pagador del fee). + /// 2. Decode postcard → (asset_id, public_key, level, verdict, severity). + /// 3. **Dedup-by-BLAKE3 con RECHAZO EXPLÍCITO**: la storage key ES el + /// `asset_id` (= BLAKE3 content hash del asset); un replay del mismo + /// attestation firmado reusa el mismo asset_id → `AlreadyAttested`. + /// (Decisión Pilar 4 sesión 6+: replay = rechazo explícito, no dedup + /// silencioso.) Va ANTES del verify para no gastar crypto en replays. + /// 4. FIX-4: gate de legitimidad ON-CHAIN — la pubkey del firmante debe + /// estar en `AuthorizedValidators` (registro governance) con nivel + /// >= 3. NO se confía en el `level` self-attested del payload (cierra + /// el squat + tier-forgery). + /// 5. PROV-6: gate anchorable (verdict Passed|Warning, severity Info|Warning). + /// 6. FIX-4/V3-C1: Ed25519 verify via `sp_io::crypto::ed25519_verify` + /// (host function, no bloatea wasm) sobre el mensaje domain-separado + /// con el `genesis_hash` de ESTA cadena (cierra replay cross-chain). + /// 7. Insert `AssetAttestation` (creator = validador, block real) + evento. #[pallet::call_index(0)] - #[pallet::weight(Weight::from_parts(10_000, 0))] + #[pallet::weight(Weight::from_parts(20_000, 0))] pub fn attest_asset( origin: OriginFor, - validation_attestation: ValidationAttestation, + signing_payload: BoundedVec, + signature: [u8; 64], ) -> DispatchResult { - // Verify origin canonical signed. - let _signer = ensure_signed(origin)?; + // 1. Origin signed (submitter — paga el fee; el creator semántico es + // el validador cuya clave está dentro del payload firmado). + let _submitter = ensure_signed(origin)?; - let asset_hash = validation_attestation.asset_id; + // 2. Decode postcard del SigningPayload real de Ergon (total, DoS-safe). + let decoded = decode_signing_payload(&signing_payload) + .map_err(|_| Error::::MalformedValidationAttestation)?; + let asset_hash = AssetHash::new(decoded.asset_id); - // PROV-2 idempotency check canonical. + // 3. Dedup-by-BLAKE3 (asset_id) — rechazo explícito de replay, antes + // del crypto (FIX-3 discipline). ensure!( !Attestations::::contains_key(asset_hash), Error::::AlreadyAttested ); - // PROV-5 OIT level >= 3 enforcement canonical. + // 4. FIX-4: legitimidad ON-CHAIN (cierra el squat + tier-forgery). + // La pubkey del firmante DEBE estar en el registro de validadores + // autorizados; el nivel es el del REGISTRO (governance), NO el + // `level` self-attested del payload. Sin esto, cualquiera con una + // clave Ed25519 anclaba basura institucional bajo el hash público + // de un asset legítimo. + let authorized_level = AuthorizedValidators::::get(decoded.public_key) + .ok_or(Error::::ValidatorNotAuthorized)?; + ensure!(authorized_level >= 3, Error::::ValidatorTierInsufficient); + + // 5. PROV-6: anchorable (Inmutable #26 — una vez anclado es inmutable). + ensure!(decoded.is_anchorable(), Error::::NotAnchorable); + + // 6. FIX-4 (V3-C1): re-derivar el mensaje domain-separado con el + // genesis_hash de ESTA cadena y verificar Ed25519 sobre los BYTES + // recibidos (nunca re-serializar). Host function = no wasm bloat. + 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 = attestation_signed_message(&signing_payload, &genesis_hash); + let sig = sp_core::ed25519::Signature::from_raw(signature); + let pubkey = sp_core::ed25519::Public::from_raw(decoded.public_key); ensure!( - validation_attestation.validator_oit.is_institutional(), - Error::::ValidatorTierInsufficient + sp_io::crypto::ed25519_verify(&sig, &message, &pubkey), + Error::::InvalidSignature ); - // PROV-6 is_anchorable check canonical. - ensure!( - validation_attestation.is_anchorable(), - Error::::NotAnchorable - ); - - // PROV-4 canonical: Real Ed25519 verification via ed25519-dalek (v0.0.16). - // - // Pattern canonical: - // 1. Reconstruct VerifyingKey desde validator_oit.public_key (32 bytes) - // 2. Reconstruct Signature desde attestation.signature (64 bytes) - // 3. Compute signing_payload canonical (SCALE encoding interim Option B) - // 4. verify_strict canonical (rejects malleable signatures + zero-keys) - // - // Cuando Ergon arkhe-noesis-attestation-types ship Cap 42+, swap canonical - // a postcard encoding canonical sin breaking pallet logic — verify call site - // remains identical canonical. - let pubkey = ed25519_dalek::VerifyingKey::from_bytes( - &validation_attestation.validator_oit.public_key, - ) - .map_err(|_| Error::::InvalidSignature)?; - - let signature = ed25519_dalek::Signature::from_bytes( - &validation_attestation.signature, - ); - - let payload = validation_attestation.signing_payload(); - - pubkey - .verify_strict(&payload, &signature) - .map_err(|_| Error::::InvalidSignature)?; - - // PROV-2 canonical: convert + insert canonical. - // SCAFFOLD: minimal AssetAttestation construction (without creator/sig - // mapping canonical — that's Ergon sub-crate concern post Cap 42+). + // 7. Anclar: creator = el validador (su Ed25519 pubkey), block real. + let block_number = >::block_number(); let attestation = AssetAttestation { hash: asset_hash, - creator: origin_chain_types::AccountId::new([0u8; 32]), // SCAFFOLD placeholder - signature: origin_chain_types::Signature::new(validation_attestation.signature), - anchored_at_block: 0, // SCAFFOLD — block_number wire post v0.0.16 - agent_chain: origin_chain_types::AgentChain::default(), // BoundedVec empty + creator: origin_chain_types::AccountId::new(decoded.public_key), + signature: origin_chain_types::Signature::new(signature), + anchored_at_block: block_number.saturated_into::(), + agent_chain: origin_chain_types::AgentChain::default(), }; - Attestations::::insert(asset_hash, attestation); - // PROV-7 emit event canonical. - let block_number = >::block_number(); - Self::deposit_event(Event::AssetAttested { - asset_hash, - block_number, - }); + Self::deposit_event(Event::AssetAttested { asset_hash, block_number }); + Ok(()) + } + /// FIX-4: registra (o re-nivela) un validador Noesis autorizado a anclar. + /// **Root-only** (sudo Index 12 / governance) — cierra el squat: solo + /// validadores que governance habilita pueden anclar. Sunset a + /// attestation-driven cuando el registro OIT de Mathi aterrice. + #[pallet::call_index(1)] + #[pallet::weight(Weight::from_parts(10_000, 0))] + pub fn register_validator( + origin: OriginFor, + public_key: [u8; 32], + level: u8, + ) -> DispatchResult { + ensure_root(origin)?; + AuthorizedValidators::::insert(public_key, level); + Self::deposit_event(Event::ValidatorRegistered { public_key, level }); + Ok(()) + } + + /// FIX-4: revoca un validador — ya no puede anclar. **Root-only.** + #[pallet::call_index(2)] + #[pallet::weight(Weight::from_parts(10_000, 0))] + pub fn revoke_validator( + origin: OriginFor, + public_key: [u8; 32], + ) -> DispatchResult { + ensure_root(origin)?; + AuthorizedValidators::::remove(public_key); + Self::deposit_event(Event::ValidatorRevoked { public_key }); Ok(()) } } @@ -320,96 +395,15 @@ mod tests_integration; #[cfg(test)] mod tests { use super::*; - use ed25519_dalek::{Signer, SigningKey}; - use origin_chain_types::{OitIdentity, SeverityTier, ValidationAttestation, ValidationVerdict}; - // Compile-time + Ed25519 verify standalone tests canonical. - // Mock runtime integration tests canonical = Phase 2.1 v0.0.17 next slice. - - fn signed_attestation(verdict: ValidationVerdict, severity: SeverityTier) -> ValidationAttestation { - let signing_key = SigningKey::from_bytes(&[7u8; 32]); - let verifying_key = signing_key.verifying_key(); - - let mut att = ValidationAttestation { - schema_version: *b"1.0.0 ", - asset_id: origin_chain_types::AssetHash::new([1u8; 32]), - validator_oit: OitIdentity { - public_key: verifying_key.to_bytes(), - level: 3, - }, - validated_at_us: 1234567890, - verdict, - severity, - constraints_checked: 42, - signature: [0u8; 64], // placeholder, replaced post sign - }; - - let payload = att.signing_payload(); - let signature = signing_key.sign(&payload); - att.signature = signature.to_bytes(); - att - } + // FIX-4: la verificación Ed25519 real (sp_io host function sobre el mensaje + // domain-separado, con golden vectors del oráculo de Ergon) vive en + // `tests_integration`. El decoder postcard + el mensaje firmado se prueban + // en `origin-chain-types::attestation_verify`. Aquí quedan compile-checks. #[test] - fn ed25519_verify_real_valid_signature_canonical() { - // PROV-4 canonical: signed attestation canonical verifies correctamente. - let att = signed_attestation(ValidationVerdict::Passed, SeverityTier::Info); - let pubkey = ed25519_dalek::VerifyingKey::from_bytes( - &att.validator_oit.public_key, - ) - .expect("valid pubkey canonical"); - let sig = ed25519_dalek::Signature::from_bytes(&att.signature); - let payload = att.signing_payload(); - assert!(pubkey.verify_strict(&payload, &sig).is_ok()); - } - - #[test] - fn ed25519_verify_tampered_signature_rejected_canonical() { - // PROV-4 canonical: tampered signature canonical rejected. - let mut att = signed_attestation(ValidationVerdict::Passed, SeverityTier::Info); - // Flip a bit en signature canonical. - att.signature[0] ^= 0xFF; - let pubkey = ed25519_dalek::VerifyingKey::from_bytes( - &att.validator_oit.public_key, - ) - .expect("valid pubkey canonical"); - let sig = ed25519_dalek::Signature::from_bytes(&att.signature); - let payload = att.signing_payload(); - assert!(pubkey.verify_strict(&payload, &sig).is_err()); - } - - #[test] - fn ed25519_verify_tampered_payload_rejected_canonical() { - // PROV-4 canonical: tampered payload canonical (signing_payload mismatch) rejected. - let mut att = signed_attestation(ValidationVerdict::Passed, SeverityTier::Info); - let original_sig = att.signature; - // Flip a bit en asset_id (cambia signing_payload pero mantiene signature original). - att.asset_id = origin_chain_types::AssetHash::new([99u8; 32]); - let pubkey = ed25519_dalek::VerifyingKey::from_bytes( - &att.validator_oit.public_key, - ) - .expect("valid pubkey canonical"); - let sig = ed25519_dalek::Signature::from_bytes(&original_sig); - let payload = att.signing_payload(); // payload canonical NEW (con asset_id tampered) - assert!(pubkey.verify_strict(&payload, &sig).is_err()); - } - - #[test] - fn ed25519_zero_signature_fails_verify_canonical() { - // PROV-4 canonical: all-zero signature canonical rejected por verify_strict. - // (Note: ed25519-dalek `from_bytes` acepta all-zero pubkey/sig — son - // valid Edwards points; el rejection canonical pasa en verify_strict.) - let signing_key = SigningKey::from_bytes(&[7u8; 32]); - let verifying_key = signing_key.verifying_key(); - let payload = b"any payload canonical"; - let zero_sig = ed25519_dalek::Signature::from_bytes(&[0u8; 64]); - assert!(verifying_key.verify_strict(payload, &zero_sig).is_err()); - } - - #[test] - fn pallet_scaffold_compiles_canonical() { - // Scaffold compiles — verified by cargo check. - // Real mock runtime integration tests = Phase 2.1 v0.0.17. + fn pallet_compiles_canonical() { + // Verified by cargo check. E2E del fee-path verify = tests_integration. } #[test] diff --git a/crates/origin-chain-provenance/src/mock.rs b/crates/origin-chain-provenance/src/mock.rs index 4a3d5d2..2021610 100644 --- a/crates/origin-chain-provenance/src/mock.rs +++ b/crates/origin-chain-provenance/src/mock.rs @@ -1,29 +1,20 @@ -//! # Mock runtime canonical para integration tests v0.0.18 +//! # Mock runtime para integration tests //! -//! Provee mock minimal `Test` runtime composition (`System` + `Provenance` solo) -//! para E2E tests dispatchable + storage + event assertions per PROV-3 flow -//! 6-step canonical: +//! Mock minimal (`System` + `Provenance`) para E2E del flow PROV-3 + FIX-4: +//! decode postcard → gates → Ed25519 verify domain-separado → anclar. //! -//! 1. PROV-4 Ed25519 signature verification -//! 2. PROV-5 validator OIT level >= 3 institutional -//! 3. PROV-6 is_anchorable check (verdict + severity) -//! 4. PROV-2 ValidationAttestation → AssetAttestation conversion -//! 5. PROV-2 StorageMap insert -//! 6. PROV-7 AssetAttested event emission -//! -//! ## Cross-reference canonical -//! -//! - DC `ORIGIN_PROVENANCE_PALLET_V1` PROV-1..8 sub-decisions -//! - `tests_integration.rs` 5 scenarios canonical E2E -//! - `lib.rs::pallet::attest_asset` dispatchable impl real +//! FIX-4: `new_test_ext_with_genesis` siembra `block_hash(0)` = el genesis con +//! el que se firmaron los golden vectors (generados desde el oráculo REAL de +//! Ergon `arkhe-noesis-core`), para probar la verificación cross-runtime. #![cfg(test)] use crate as origin_chain_provenance; use frame_support::derive_impl; +use sp_core::H256; use sp_runtime::BuildStorage; -/// Mock block type canonical para tests. +/// Mock block type para tests. type Block = frame_system::mocking::MockBlock; frame_support::construct_runtime!( @@ -44,34 +35,52 @@ impl origin_chain_provenance::Config for Test { type RuntimeEvent = RuntimeEvent; } -/// Test externalities builder canonical. -/// -/// Returns new `sp_io::TestExternalities` con frame_system genesis configurado -/// y block_number set to 1 (PROV-7 events requieren block_number > 0 para -/// proper testing canonical). -pub fn new_test_ext() -> sp_io::TestExternalities { +/// Genesis hash de los golden vectors ASP-3 (`/tmp/golden-asp3`, GENESIS=0xAB×32). +/// El pallet lee `block_hash(0)`; el mock lo siembra con ESTE valor para que la +/// verificación Ed25519 coincida con las firmas del oráculo. +pub const GOLDEN_GENESIS: [u8; 32] = [0xABu8; 32]; + +/// Pubkey del validador golden (seed [7u8;32] → todos los vectores la usan). +pub const GOLDEN_VALIDATOR_PUBKEY: [u8; 32] = [ + 0xea, 0x4a, 0x6c, 0x63, 0xe2, 0x9c, 0x52, 0x0a, 0xbe, 0xf5, 0x50, 0x7b, 0x13, 0x2e, 0xc5, 0xf9, + 0x95, 0x47, 0x76, 0xae, 0xbe, 0xbe, 0x7b, 0x92, 0x42, 0x1e, 0xea, 0x69, 0x14, 0x46, 0xd2, 0x2c, +]; + +/// Registra el validador golden con el nivel dado (vía Root) — helper de tests. +pub fn register_golden_validator(level: u8) { + crate::pallet::Pallet::::register_validator( + RuntimeOrigin::root(), + GOLDEN_VALIDATOR_PUBKEY, + level, + ) + .expect("Root registra el validador"); +} + +/// Test externalities con `block_hash(0)` sembrado al genesis dado. +pub fn new_test_ext_with_genesis(genesis: [u8; 32]) -> sp_io::TestExternalities { let mut ext: sp_io::TestExternalities = frame_system::GenesisConfig::::default() .build_storage() - .expect("genesis canonical builds") + .expect("genesis builds") .into(); ext.execute_with(|| { System::set_block_number(1); + // Siembra el hash del bloque génesis (que el pallet lee para el dominio). + frame_system::BlockHash::::insert(0u64, H256(genesis)); }); ext } -/// Test signing key seed canonical (deterministic Ed25519 keypair). -/// -/// Per ed25519-dalek pattern: seed [7u8; 32] produces deterministic verifying_key. -/// Used across integration tests para reproducible signing. -pub const TEST_SIGNING_SEED: [u8; 32] = [7u8; 32]; +/// Test externalities con el genesis de los golden vectors (caso por defecto). +pub fn new_test_ext() -> sp_io::TestExternalities { + new_test_ext_with_genesis(GOLDEN_GENESIS) +} -/// Test origin account canonical (32-byte zero pattern). +/// Submitter de prueba (un relayer cualquiera — paga el fee, no es el validador). pub fn alice_origin() -> RuntimeOrigin { RuntimeOrigin::signed(sp_runtime::AccountId32::new([1u8; 32])) } -/// Test origin account canonical (BOB pattern). +/// Segundo submitter (BOB). pub fn bob_origin() -> RuntimeOrigin { RuntimeOrigin::signed(sp_runtime::AccountId32::new([2u8; 32])) } diff --git a/crates/origin-chain-provenance/src/tests_integration.rs b/crates/origin-chain-provenance/src/tests_integration.rs index 90b2637..fa31cfc 100644 --- a/crates/origin-chain-provenance/src/tests_integration.rs +++ b/crates/origin-chain-provenance/src/tests_integration.rs @@ -1,310 +1,241 @@ -//! # Integration tests canonical v0.0.18 — Mock runtime E2E +//! # Integration tests E2E — FIX-4 verificador attestation cross-runtime //! -//! 5 scenarios canonical PROV-3 attest_asset flow end-to-end via mock runtime: +//! Prueban el dispatchable `attest_asset` contra **golden vectors generados +//! desde el oráculo REAL de Ergon** (`arkhe-noesis-core::attestation`, en +//! `/tmp/golden-asp3` con keypair seed [7u8;32], genesis 0xAB×32). Esto cierra +//! el caso V3-C1: una firma producida por el código real de Ergon (postcard + +//! mensaje domain-separado) verifica byte-fiel en el pallet Substrate. //! -//! | Scenario | Verdict | Severity | OIT Level | Expected outcome | -//! |----------|---------|----------|-----------|------------------| -//! | passed_info_anchored | Passed | Info | 3 | ✅ AssetAttested event + storage insert | -//! | warning_anchored | Warning | Warning | 3 | ✅ AssetAttested event + storage insert | -//! | failed_critical_rejected | Failed | Critical | 3 | ❌ NotAnchorable error | -//! | passed_block_security_rejected | Passed | Block | 3 | ❌ NotAnchorable error | -//! | anonymous_level_0_rejected | Passed | Info | 0 | ❌ ValidatorTierInsufficient error | -//! -//! Plus 4 additional canonical: -//! - idempotency: already attested asset rejected con `AlreadyAttested` error -//! - tampered_signature: Ed25519 verify fails con `InvalidSignature` error -//! - tampered_payload: signing_payload mismatch con `InvalidSignature` error -//! - 6_step_walkthrough: explicit PROV-3 flow assertions in sequence -//! -//! Total: 9 integration tests canonical. -//! -//! ## Own fixtures pattern -//! -//! Per audit canonical sesión 3+ (`AUDIT_CHARON_SESION_3_STATE_V1.md`), Ergon -//! E2E fixture diary claim aspirational sin shipped commit verified. -//! Charon construye own fixtures basados en vendor copy `ValidationAttestation` -//! real existente en `origin-chain-types/src/attestation.rs`. Cuando Ergon -//! ship `arkhe-noesis-attestation-types` sub-crate Cap 42+, fixtures can swap. +//! Vectores (hex): +//! - PAYLOAD1/SIG1: asset [1×32], atelier level 3, Passed/Info → ANCLA. +//! - PAYLOAD2/SIG2: asset [2×32], atelier level 3, Warning/Warning → ANCLA. +//! - PAYLOAD3/SIG3: asset [3×32], atelier level 3, Failed/Critical → NotAnchorable. +//! - PAYLOAD4/SIG4: asset [4×32], anonymous level 0, Passed/Info → TierInsufficient. #![cfg(test)] -use crate::mock::{alice_origin, bob_origin, new_test_ext, Provenance, RuntimeEvent, System, Test, TEST_SIGNING_SEED}; -use crate::{Attestations, Error, Event}; -use ed25519_dalek::{Signer, SigningKey}; -use frame_support::{assert_noop, assert_ok}; -use origin_chain_types::{ - AssetHash, OitIdentity, SeverityTier, ValidationAttestation, ValidationVerdict, +extern crate alloc; + +use crate::mock::{ + alice_origin, bob_origin, new_test_ext, new_test_ext_with_genesis, register_golden_validator, + Provenance, RuntimeEvent, RuntimeOrigin, System, Test, GOLDEN_GENESIS, GOLDEN_VALIDATOR_PUBKEY, }; +use crate::{Attestations, Error, Event}; +use frame_support::{assert_noop, assert_ok, BoundedVec}; -// ─── Fixture builders canonical ────────────────────────────────────── +// ── Golden vectors (oráculo Ergon real) ────────────────────────────── +const PAYLOAD1: &str = "05312e302e300101010101010101010101010101010101010101010101010101010101010101146174656c6965725f63616e6f6e6963616c5f763103ea4a6c63e29c520abef5507b132ec5f9954776aebebe7b92421eea691446d22cd285d8cc0400002a"; +const SIG1: &str = "e155043ad90df787d73e8ba4c3ee961427ecb228637ade77e19bd9b57bb727dcdf3c247462aa8e6facf5697887f3e08f96af1c5f2fdcab10fcfbd2daabe73204"; +const PAYLOAD2: &str = "05312e302e300202020202020202020202020202020202020202020202020202020202020202146174656c6965725f63616e6f6e6963616c5f763103ea4a6c63e29c520abef5507b132ec5f9954776aebebe7b92421eea691446d22c8080f9c0c1c48203010107"; +const SIG2: &str = "34aa4a272ea6b264a1a38af912a9dfee9e77a3d37f0ae2427634d9aa0e5582eca741787904b8d0199614f93c14f2d5f3b7a53e1414d29d7ee272e985591fee05"; +const PAYLOAD3: &str = "05312e302e300303030303030303030303030303030303030303030303030303030303030303146174656c6965725f63616e6f6e6963616c5f763103ea4a6c63e29c520abef5507b132ec5f9954776aebebe7b92421eea691446d22c8080f9c0c1c48203020203"; +const SIG3: &str = "2c91a93223a105e528ac17879bc6561444ab8af3aac5b0857d781cb6d2c2e52eca6e6ec6673c174dc3cdf28733add7a0dce23e2f543288721e3ad84ddb77fa05"; +const PAYLOAD4: &str = "05312e302e30040404040404040404040404040404040404040404040404040404040404040413616e6f6e796d6f75735f63616e6f6e6963616c00ea4a6c63e29c520abef5507b132ec5f9954776aebebe7b92421eea691446d22c8080f9c0c1c48203000001"; +const SIG4: &str = "53898be4c2dfea096fb991a9d37b2ffe078f8ff2a20003612bb6930eefd0ddeeb0056a48b4a55453c519eb75bbfb5d6b05295a5f91050f53087125145b392702"; -/// Build canonical signed ValidationAttestation con (verdict, severity, oit_level) -/// parameters. Deterministic Ed25519 signature. -/// -/// asset_id es derivado de seed para uniqueness per scenario. -fn build_signed_attestation( - asset_seed: u8, - verdict: ValidationVerdict, - severity: SeverityTier, - oit_level: u8, -) -> ValidationAttestation { - let signing_key = SigningKey::from_bytes(&TEST_SIGNING_SEED); - let verifying_key = signing_key.verifying_key(); - - let mut att = ValidationAttestation { - schema_version: *b"1.0.0 ", - asset_id: AssetHash::new([asset_seed; 32]), - validator_oit: OitIdentity { - public_key: verifying_key.to_bytes(), - level: oit_level, - }, - validated_at_us: 1_234_567_890, - verdict, - severity, - constraints_checked: 42, - signature: [0u8; 64], // placeholder, replaced post sign - }; - - let payload = att.signing_payload(); - let signature = signing_key.sign(&payload); - att.signature = signature.to_bytes(); - att +fn unhex(s: &str) -> alloc::vec::Vec { + (0..s.len()).step_by(2).map(|i| u8::from_str_radix(&s[i..i + 2], 16).unwrap()).collect() } -/// Build canonical fixture passed_info (most common anchorable scenario). -fn fixture_passed_info(asset_seed: u8) -> ValidationAttestation { - build_signed_attestation(asset_seed, ValidationVerdict::Passed, SeverityTier::Info, 3) +fn payload(s: &str) -> BoundedVec { + BoundedVec::try_from(unhex(s)).expect("payload within bound") } -// ─── Scenario 1: passed_info anchored canonical ────────────────────── +fn sig(s: &str) -> [u8; 64] { + unhex(s).try_into().expect("64-byte sig") +} + +// ── Camino feliz: firma real de Ergon ancla on-chain ───────────────── #[test] -fn scenario_1_passed_info_anchored_canonical() { +fn golden_passed_info_anchors_cross_runtime() { new_test_ext().execute_with(|| { - let attestation = fixture_passed_info(1); - let asset_hash = attestation.asset_id; - - // PROV-3 attest_asset extrinsic canonical - assert_ok!(Provenance::attest_asset(alice_origin(), attestation.clone())); - - // PROV-2 storage insert verified - let stored = Attestations::::get(asset_hash); - assert!(stored.is_some(), "PROV-2 canonical: AssetAttestation inserted in StorageMap"); - let stored_unwrap = stored.unwrap(); - assert_eq!(stored_unwrap.hash, asset_hash); - assert_eq!(stored_unwrap.signature.as_bytes(), &attestation.signature); - - // PROV-7 event emitted canonical - System::assert_last_event(RuntimeEvent::Provenance(Event::AssetAttested { - asset_hash, + register_golden_validator(3); // governance habilita al validador + assert_ok!(Provenance::attest_asset(alice_origin(), payload(PAYLOAD1), sig(SIG1))); + let hash = origin_chain_types::AssetHash::new([1u8; 32]); + let stored = Attestations::::get(hash).expect("anclado"); + // creator = el VALIDADOR (su pubkey Ed25519), no el submitter. + assert_eq!(stored.creator.as_bytes(), &GOLDEN_VALIDATOR_PUBKEY[..]); + // block real (no el placeholder 0 del scaffold). + assert_eq!(stored.anchored_at_block, 1); + System::assert_has_event(RuntimeEvent::Provenance(Event::AssetAttested { + asset_hash: hash, block_number: 1, })); }); } -// ─── Scenario 2: warning anchored canonical (non-blocking) ──────────── - #[test] -fn scenario_2_warning_anchored_canonical() { +fn golden_warning_warning_anchors() { new_test_ext().execute_with(|| { - let attestation = build_signed_attestation( - 2, - ValidationVerdict::Warning, - SeverityTier::Warning, - 3, - ); - let asset_hash = attestation.asset_id; - - assert_ok!(Provenance::attest_asset(alice_origin(), attestation)); - - assert!(Attestations::::get(asset_hash).is_some()); - System::assert_last_event(RuntimeEvent::Provenance(Event::AssetAttested { - asset_hash, - block_number: 1, - })); + register_golden_validator(3); + assert_ok!(Provenance::attest_asset(alice_origin(), payload(PAYLOAD2), sig(SIG2))); + assert!(Attestations::::contains_key(origin_chain_types::AssetHash::new([2u8; 32]))); }); } -// ─── Scenario 3: failed_critical rejected canonical (PROV-6) ────────── +// ── FIX-4: legitimidad ON-CHAIN (cierra squat + tier-forgery) ──────── #[test] -fn scenario_3_failed_critical_rejected_canonical() { +fn unregistered_validator_rejected_closes_squat() { + // Firma VÁLIDA + level 3 en el payload, pero la pubkey NO está registrada + // → rechazo. Cierra el squat: un atacante anónimo no ancla nada. new_test_ext().execute_with(|| { - let attestation = build_signed_attestation( - 3, - ValidationVerdict::Failed, - SeverityTier::Critical, - 3, - ); - let asset_hash = attestation.asset_id; - - // PROV-6 is_anchorable canonical check fails (Failed verdict + Critical severity) assert_noop!( - Provenance::attest_asset(alice_origin(), attestation), - Error::::NotAnchorable + Provenance::attest_asset(alice_origin(), payload(PAYLOAD1), sig(SIG1)), + Error::::ValidatorNotAuthorized ); - - // Storage NOT inserted - assert!(Attestations::::get(asset_hash).is_none()); + assert!(!Attestations::::contains_key(origin_chain_types::AssetHash::new([1u8; 32]))); }); } -// ─── Scenario 4: passed_block security rejected canonical (PROV-6) ──── - #[test] -fn scenario_4_passed_block_security_rejected_canonical() { +fn registered_but_low_level_rejected() { new_test_ext().execute_with(|| { - // Passed verdict pero Block severity (security violation override) - let attestation = build_signed_attestation( - 4, - ValidationVerdict::Passed, - SeverityTier::Block, - 3, - ); - let asset_hash = attestation.asset_id; - - // PROV-6 is_anchorable rejects Block severity even with Passed verdict + register_golden_validator(1); // registrado, pero nivel < 3 assert_noop!( - Provenance::attest_asset(alice_origin(), attestation), - Error::::NotAnchorable - ); - - assert!(Attestations::::get(asset_hash).is_none()); - }); -} - -// ─── Scenario 5: anonymous_level_0 rejected canonical (PROV-5) ──────── - -#[test] -fn scenario_5_anonymous_level_0_rejected_canonical() { - new_test_ext().execute_with(|| { - // Valid verdict + severity, pero OIT level 0 (anonymous) — PROV-5 reject - let attestation = build_signed_attestation( - 5, - ValidationVerdict::Passed, - SeverityTier::Info, - 0, // ANONYMOUS — institutional level >= 3 required - ); - let asset_hash = attestation.asset_id; - - // PROV-5 validator OIT tier check canonical fails - assert_noop!( - Provenance::attest_asset(alice_origin(), attestation), + Provenance::attest_asset(alice_origin(), payload(PAYLOAD1), sig(SIG1)), Error::::ValidatorTierInsufficient ); - - assert!(Attestations::::get(asset_hash).is_none()); }); } -// ─── Additional canonical: idempotency ──────────────────────────────── +#[test] +fn registry_level_overrides_payload_self_claimed_level() { + // PAYLOAD4 declara identifier "anonymous" + level 0 EN EL PAYLOAD, pero la + // pubkey está registrada a nivel 3 → ancla. La VERDAD es el registro, no el + // self-attested del payload (esto es exactamente lo que cierra el forgery). + new_test_ext().execute_with(|| { + register_golden_validator(3); + assert_ok!(Provenance::attest_asset(alice_origin(), payload(PAYLOAD4), sig(SIG4))); + assert!(Attestations::::contains_key(origin_chain_types::AssetHash::new([4u8; 32]))); + }); +} #[test] -fn additional_idempotency_already_attested_rejected_canonical() { +fn register_validator_is_root_only() { new_test_ext().execute_with(|| { - let attestation = fixture_passed_info(6); - let asset_hash = attestation.asset_id; - - // First attest_asset succeeds canonical - assert_ok!(Provenance::attest_asset(alice_origin(), attestation.clone())); - assert!(Attestations::::get(asset_hash).is_some()); - - // Second attest_asset (same hash) — idempotency canonical rejects + // Signed origin → BadOrigin (solo governance habilita validadores). assert_noop!( - Provenance::attest_asset(bob_origin(), attestation), + Provenance::register_validator(alice_origin(), GOLDEN_VALIDATOR_PUBKEY, 3), + sp_runtime::DispatchError::BadOrigin + ); + }); +} + +#[test] +fn revoked_validator_cannot_anchor() { + new_test_ext().execute_with(|| { + register_golden_validator(3); + assert_ok!(Provenance::revoke_validator(RuntimeOrigin::root(), GOLDEN_VALIDATOR_PUBKEY)); + assert_noop!( + Provenance::attest_asset(alice_origin(), payload(PAYLOAD1), sig(SIG1)), + Error::::ValidatorNotAuthorized + ); + }); +} + +// ── Gate anchorable ────────────────────────────────────────────────── + +#[test] +fn golden_failed_critical_rejected_not_anchorable() { + new_test_ext().execute_with(|| { + register_golden_validator(3); + assert_noop!( + Provenance::attest_asset(alice_origin(), payload(PAYLOAD3), sig(SIG3)), + Error::::NotAnchorable + ); + }); +} + +// ── Crypto: tamper + anti-replay cross-chain (V3-C1) ───────────────── + +#[test] +fn tampered_signature_rejected() { + new_test_ext().execute_with(|| { + register_golden_validator(3); + let mut bad = sig(SIG1); + bad[0] ^= 0xFF; + assert_noop!( + Provenance::attest_asset(alice_origin(), payload(PAYLOAD1), bad), + Error::::InvalidSignature + ); + }); +} + +#[test] +fn tampered_payload_rejected() { + new_test_ext().execute_with(|| { + register_golden_validator(3); + // PAYLOAD2 con SIG1: la firma no corresponde a estos bytes. + assert_noop!( + Provenance::attest_asset(alice_origin(), payload(PAYLOAD2), sig(SIG1)), + Error::::InvalidSignature + ); + }); +} + +#[test] +fn wrong_genesis_rejected_anti_replay_cross_chain() { + // V3-C1: la MISMA attestation firmada para la cadena 0xAB no verifica en + // una cadena con genesis distinto (0xCC) — el genesis_hash liga la firma. + new_test_ext_with_genesis([0xCCu8; 32]).execute_with(|| { + register_golden_validator(3); + assert_noop!( + Provenance::attest_asset(alice_origin(), payload(PAYLOAD1), sig(SIG1)), + Error::::InvalidSignature + ); + }); +} + +// ── Replay intra-chain: rechazo EXPLÍCITO (decisión Pilar 4) ───────── + +#[test] +fn replay_same_attestation_rejected_explicitly() { + new_test_ext().execute_with(|| { + register_golden_validator(3); + assert_ok!(Provenance::attest_asset(alice_origin(), payload(PAYLOAD1), sig(SIG1))); + // Re-submit del MISMO attestation firmado (incluso por otro submitter): + // mismo asset_id (BLAKE3 content hash) → rechazo explícito. + assert_noop!( + Provenance::attest_asset(bob_origin(), payload(PAYLOAD1), sig(SIG1)), Error::::AlreadyAttested ); }); } -// ─── Additional canonical: tampered signature (PROV-4) ──────────────── +// ── Malformed payload ──────────────────────────────────────────────── #[test] -fn additional_tampered_signature_rejected_canonical() { +fn malformed_payload_rejected() { new_test_ext().execute_with(|| { - let mut attestation = fixture_passed_info(7); - let asset_hash = attestation.asset_id; - - // Flip bit en signature canonical post-sign - attestation.signature[0] ^= 0xFF; - - // PROV-4 Ed25519 verify canonical fails + let mut bytes = unhex(PAYLOAD1); + bytes.push(0x99); // trailing byte → decoder estricto rechaza + let bad = BoundedVec::try_from(bytes).unwrap(); assert_noop!( - Provenance::attest_asset(alice_origin(), attestation), - Error::::InvalidSignature + Provenance::attest_asset(alice_origin(), bad, sig(SIG1)), + Error::::MalformedValidationAttestation ); - - assert!(Attestations::::get(asset_hash).is_none()); }); } -// ─── Additional canonical: tampered payload (PROV-4) ────────────────── - #[test] -fn additional_tampered_payload_rejected_canonical() { +fn empty_payload_rejected() { new_test_ext().execute_with(|| { - let mut attestation = fixture_passed_info(8); - let original_asset_hash = attestation.asset_id; - - // Tamper asset_id post-sign — cambia signing_payload pero mantiene signature original - attestation.asset_id = AssetHash::new([99u8; 32]); - let new_asset_hash = attestation.asset_id; - - // PROV-4 signing_payload mismatch canonical, Ed25519 verify fails + let empty = BoundedVec::try_from(alloc::vec::Vec::new()).unwrap(); assert_noop!( - Provenance::attest_asset(alice_origin(), attestation), - Error::::InvalidSignature + Provenance::attest_asset(alice_origin(), empty, [0u8; 64]), + Error::::MalformedValidationAttestation ); - - // Ninguno de los dos hashes está en storage - assert!(Attestations::::get(original_asset_hash).is_none()); - assert!(Attestations::::get(new_asset_hash).is_none()); }); } -// ─── Additional canonical: 6-step PROV-3 walkthrough explicit ───────── +// ── Determinismo: el genesis correcto SÍ ancla ─────────────────────── #[test] -fn additional_6_step_prov_3_walkthrough_canonical() { - new_test_ext().execute_with(|| { - let attestation = fixture_passed_info(9); - let asset_hash = attestation.asset_id; - - // Pre-flight assertions canonical - assert!( - attestation.is_anchorable(), - "PRE-flight PROV-6: fixture anchorable canonical" - ); - assert!( - attestation.validator_oit.is_institutional(), - "PRE-flight PROV-5: validator institutional canonical" - ); - assert!( - Attestations::::get(asset_hash).is_none(), - "PRE-flight PROV-2: storage empty para asset_hash canonical" - ); - - // Step 1: PROV-4 canonical Ed25519 signature verification (implicit via dispatchable) - // Step 2: PROV-5 canonical validate validator_oit.level >= 3 institutional - // Step 3: PROV-6 canonical check is_anchorable (verdict + severity) - // Step 4: PROV-2 canonical convert ValidationAttestation → AssetAttestation - // Step 5: PROV-2 canonical StorageMap insert - // Step 6: PROV-7 canonical emit AssetAttested event - assert_ok!(Provenance::attest_asset(alice_origin(), attestation.clone())); - - // Post-flight assertions canonical - let stored = Attestations::::get(asset_hash) - .expect("POST-flight PROV-2: AssetAttestation persisted canonical"); - assert_eq!(stored.hash, asset_hash, "POST-flight: hash preserved"); - assert_eq!( - stored.signature.as_bytes(), - &attestation.signature, - "POST-flight: signature preserved canonical" - ); - - System::assert_last_event(RuntimeEvent::Provenance(Event::AssetAttested { - asset_hash, - block_number: 1, - })); +fn correct_genesis_verifies() { + new_test_ext_with_genesis(GOLDEN_GENESIS).execute_with(|| { + register_golden_validator(3); + assert_ok!(Provenance::attest_asset(alice_origin(), payload(PAYLOAD1), sig(SIG1))); }); } diff --git a/crates/origin-chain-types/src/attestation.rs b/crates/origin-chain-types/src/attestation.rs index 591d15c..f37b112 100644 --- a/crates/origin-chain-types/src/attestation.rs +++ b/crates/origin-chain-types/src/attestation.rs @@ -19,9 +19,6 @@ //! - Update re-exports lib.rs canonical //! - Diary canonical Ergon coordination switch -extern crate alloc; - -use alloc::vec::Vec; use serde_big_array::BigArray; use crate::AssetHash; @@ -139,26 +136,14 @@ impl ValidationAttestation { verdict_ok && severity_ok } - /// Returns signing payload canonical (postcard de fields EXCEPT signature). - /// - /// Per Ergon canonical pattern Slice 7.0r: - /// ```text - /// payload = postcard(ValidationAttestation { ..., signature: [0; 64] }) - /// ``` - /// - /// **VENDOR canonical NOTE**: stub implementation canonical. Real impl en - /// `arkhe-noesis-attestation-types` usa `postcard` crate canonical. Aquí - /// usamos parity-scale-codec `Encode` para compat FRAME canonical pallet - /// runtime canonical. Esto puede diverger del Ergon real canonical — DC - /// PROVENANCE_PALLET_V1 PROV-4 confirma Ed25519 verification handled - /// directly via ed25519-dalek canonical en pallet (trait-seam ADR-0057). - pub fn signing_payload(&self) -> Vec { - use parity_scale_codec::Encode; - // Clone with signature zeroed canonical para deterministic payload. - let mut payload_struct = self.clone(); - payload_struct.signature = [0u8; 64]; - payload_struct.encode() - } + // FIX-4 (sesión 7+): el método `signing_payload()` SCALE fue REMOVIDO. + // Producía bytes byte-DIVERGENTES del postcard real de Ergon (su propio doc + // lo admitía) → un firmante que lo usara generaba firmas que NUNCA + // verificarían contra el verificador on-chain. El verificador real + // (`crate::attestation_verify`) opera sobre los bytes postcard opacos del + // oráculo de Ergon; este método muerto era un footgun (flagged en la + // verificación adversarial). El struct se conserva como type-mirror hasta + // que aterrice `arkhe-noesis-attestation-types`. } #[cfg(test)] @@ -228,23 +213,6 @@ mod tests { assert!(att.is_anchorable()); } - #[test] - fn signing_payload_zeroes_signature_canonical() { - let att = mock_attestation(); - let payload = att.signing_payload(); - // Signature bytes (last 64 in encode order) should be zeros canonical. - // Verify payload non-empty + deterministic. - assert!(!payload.is_empty()); - } - - #[test] - fn signing_payload_deterministic_canonical() { - let att = mock_attestation(); - let p1 = att.signing_payload(); - let p2 = att.signing_payload(); - assert_eq!(p1, p2); - } - #[test] fn validation_attestation_serde_roundtrip_canonical() { let att = mock_attestation(); diff --git a/crates/origin-chain-types/src/attestation_verify.rs b/crates/origin-chain-types/src/attestation_verify.rs new file mode 100644 index 0000000..4d8aa3f --- /dev/null +++ b/crates/origin-chain-types/src/attestation_verify.rs @@ -0,0 +1,378 @@ +//! # Verificador de attestation on-chain — FIX-4 (V3-C1 + firma legítima) +//! +//! Espejo byte-fiel del oráculo de Ergon (`arkhe-noesis-core::attestation`, +//! DC `076d15cc` ASP-3 ratificado 2/2 con Charon 2026-06-09). Dos piezas: +//! +//! 1. [`ATTESTATION_DOMAIN_TAG`] + [`attestation_signed_message`] — el mensaje +//! domain-separado que el validador firma off-chain y el pallet re-deriva: +//! `TAG ‖ genesis_hash(32) ‖ signing_payload`. El `genesis_hash` liga la +//! firma a UNA cadena (cierra el replay cross-chain V3-C1). Copiados +//! byte-idénticos del oráculo; cambiar el TAG es un cambio de protocolo que +//! requiere re-ratificar con Pilar 4. +//! +//! 2. [`decode_signing_payload`] — un lector **postcard secuencial total** del +//! `SigningPayload` real de Ergon. El contrato ASP-3 exige verificar sobre +//! los BYTES recibidos (nunca re-serializar): este decoder solo EXTRAE los +//! campos que el pallet necesita para sus gates (asset_id, public_key, level, +//! verdict, severity) avanzando el cursor por los demás. Sin dep nueva en el +//! runtime wasm (frágil); con bounds DoS explícitos. La compatibilidad +//! byte-fiel con el oráculo se prueba con golden vectors generados desde el +//! crate real de Ergon (ver tests del pallet). +//! +//! ## Layout postcard del `SigningPayload` (orden de declaración de Ergon) +//! +//! | # | campo | encoding postcard | +//! |---|-------|-------------------| +//! | 1 | `schema_version: &str` | varint len (LEB128) + UTF-8 | +//! | 2 | `asset_id: [u8;32]` | 32 bytes crudos (array fijo, sin prefix) | +//! | 3a | `validator_oit.identifier: String` | varint len + UTF-8 | +//! | 3b | `validator_oit.level: u8` | 1 byte crudo (u8 NO es varint) | +//! | 3c | `validator_oit.public_key: [u8;32]` | 32 bytes crudos | +//! | 4 | `validated_at_us: u64` | varint LEB128 | +//! | 5 | `verdict: enum` | varint discriminant (índice de declaración) | +//! | 6 | `severity: enum` | varint discriminant | +//! | 7 | `constraints_checked: u32` | varint LEB128 | +//! +//! Nota crítica (heredada del mapeo): postcard serializa enums por **índice de +//! variante** (orden de declaración), NO por `#[repr(u8)]`. Hoy coinciden +//! (Passed=0/Warning=1/Failed=2, Info=0/…/Block=3); el decoder valida el rango. + +extern crate alloc; + +use alloc::vec::Vec; + +/// Domain tag canónico (DC `076d15cc` ASP-3). 27 bytes ASCII. Byte-idéntico al +/// oráculo de Ergon `arkhe-noesis-core::attestation::ATTESTATION_DOMAIN_TAG`. +pub const ATTESTATION_DOMAIN_TAG: &[u8] = b"origin-chain/attestation/v1"; + +/// Construye el mensaje firmado domain-separado: `TAG ‖ genesis_hash ‖ payload`. +/// Espejo byte-idéntico de `attestation_signed_message` del oráculo. El +/// verificador re-deriva ESTO (no el payload crudo) antes de `ed25519_verify`. +pub fn attestation_signed_message(signing_payload: &[u8], genesis_hash: &[u8; 32]) -> Vec { + let mut message = + Vec::with_capacity(ATTESTATION_DOMAIN_TAG.len() + genesis_hash.len() + signing_payload.len()); + message.extend_from_slice(ATTESTATION_DOMAIN_TAG); + message.extend_from_slice(genesis_hash); + message.extend_from_slice(signing_payload); + message +} + +/// Cota DoS del identifier de `OitIdentity` (el tipo Rust es `String` sin +/// límite; on-chain imponemos uno). Los identifiers canónicos son cortos +/// ("atelier_canonical_v1" = 20, "anonymous_canonical" = 19). +pub const MAX_OIT_IDENTIFIER_LEN: u32 = 64; + +/// Cota DoS del schema_version (semver string). +pub const MAX_SCHEMA_VERSION_LEN: u32 = 16; + +/// Campos extraídos del `SigningPayload` postcard — solo los que el pallet +/// necesita para sus gates. El `validated_at_us` y `constraints_checked` se +/// decodifican para avanzar el cursor pero no se exponen (no gobiernan gates). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct DecodedSigningPayload { + /// BLAKE3 content hash del asset (storage key + dedup). + pub asset_id: [u8; 32], + /// Ed25519 public key del validador (con la que se verifica la firma). + pub public_key: [u8; 32], + /// Nivel OIT del validador (gate institucional >= 3). + pub level: u8, + /// Verdict (0=Passed, 1=Warning, 2=Failed). + pub verdict: u8, + /// Severity (0=Info, 1=Warning, 2=Critical, 3=Block). + pub severity: u8, +} + +impl DecodedSigningPayload { + /// `true` si el validador es institucional (level >= 3) — PROV-5. + pub fn is_institutional(&self) -> bool { + self.level >= 3 + } + + /// `true` si la attestation es anchorable: verdict Passed|Warning AND + /// severity Info|Warning (PROV-6, Inmutable #26 immutabilidad post-anchor). + pub fn is_anchorable(&self) -> bool { + let verdict_ok = self.verdict <= 1; // Passed | Warning + let severity_ok = self.severity <= 1; // Info | Warning + verdict_ok && severity_ok + } +} + +/// Error de decodificación del payload postcard (DoS-safe, total). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SigningPayloadError { + /// Se acabaron los bytes antes de completar un campo. + UnexpectedEnd, + /// Un varint LEB128 excede 10 bytes (malformado) o desborda u64. + VarintOverflow, + /// Un length-prefix excede su cota DoS. + LengthTooLarge, + /// Discriminant de enum fuera de rango (verdict > 2 o severity > 3). + InvalidDiscriminant, + /// Quedaron bytes sin consumir tras el último campo (payload no estricto). + TrailingBytes, +} + +/// Cursor minimal sobre `&[u8]` con lectura postcard. +struct Reader<'a> { + buf: &'a [u8], + pos: usize, +} + +impl<'a> Reader<'a> { + fn new(buf: &'a [u8]) -> Self { + Self { buf, pos: 0 } + } + + fn read_byte(&mut self) -> Result { + let b = *self.buf.get(self.pos).ok_or(SigningPayloadError::UnexpectedEnd)?; + self.pos += 1; + Ok(b) + } + + /// Lee un varint LEB128 unsigned u64 (postcard `try_take_varint_u64`): + /// máx 10 bytes, el 10º ≤ 1. Usado para `validated_at_us: u64`. + fn read_varint_u64(&mut self) -> Result { + let mut result: u64 = 0; + let mut shift: u32 = 0; + loop { + if shift >= 64 { + return Err(SigningPayloadError::VarintOverflow); + } + let byte = self.read_byte()?; + let low = (byte & 0x7f) as u64; + // El último grupo de un u64 son 64-63=1 bit ⇒ el 10º byte ≤ 1. + if shift == 63 && low > 1 { + return Err(SigningPayloadError::VarintOverflow); + } + result |= low << shift; + if byte & 0x80 == 0 { + return Ok(result); + } + shift += 7; + } + } + + /// Lee un varint LEB128 unsigned u32 ESTRICTO (postcard `try_take_varint_u32`): + /// máx 5 bytes, el 5º ≤ 15. Postcard codifica los discriminants de enum y + /// `u32` así; usar el lector u64 aceptaría formas sobre-largas que + /// `postcard::from_bytes` rechaza (fidelidad de contrato — el verificador + /// caza esto). Usado para `verdict`/`severity`/`constraints_checked`. + fn read_varint_u32(&mut self) -> Result { + let mut result: u32 = 0; + let mut shift: u32 = 0; + loop { + if shift >= 32 { + return Err(SigningPayloadError::VarintOverflow); + } + let byte = self.read_byte()?; + let low = (byte & 0x7f) as u32; + // El último grupo de un u32 son 32-28=4 bits ⇒ el 5º byte ≤ 15. + if shift == 28 && low > 0x0f { + return Err(SigningPayloadError::VarintOverflow); + } + result |= low << shift; + if byte & 0x80 == 0 { + return Ok(result); + } + shift += 7; + } + } + + fn read_array32(&mut self) -> Result<[u8; 32], SigningPayloadError> { + let end = self.pos.checked_add(32).ok_or(SigningPayloadError::UnexpectedEnd)?; + let slice = self.buf.get(self.pos..end).ok_or(SigningPayloadError::UnexpectedEnd)?; + let mut out = [0u8; 32]; + out.copy_from_slice(slice); + self.pos = end; + Ok(out) + } + + /// Salta una cadena length-prefixed (varint u32 len + bytes), validando la + /// cota. Postcard codifica el length de strings como varint u32. + fn skip_string(&mut self, max_len: u32) -> Result<(), SigningPayloadError> { + let len = self.read_varint_u32()?; + if len > max_len { + return Err(SigningPayloadError::LengthTooLarge); + } + let len = len as usize; + let end = self.pos.checked_add(len).ok_or(SigningPayloadError::UnexpectedEnd)?; + // Verifica que existan los bytes (sin copiarlos: no necesitamos el valor). + let _ = self.buf.get(self.pos..end).ok_or(SigningPayloadError::UnexpectedEnd)?; + self.pos = end; + Ok(()) + } +} + +/// Decodifica un `SigningPayload` postcard de Ergon extrayendo los campos que +/// gobiernan los gates del pallet. **Total y estricto**: rechaza payloads +/// malformados, truncados, con length-prefix excesivo, discriminant inválido o +/// bytes sobrantes. NO re-serializa nada — el `signing_payload` original es lo +/// que se firma/verifica (contrato ASP-3). +pub fn decode_signing_payload(payload: &[u8]) -> Result { + let mut r = Reader::new(payload); + + // 1. schema_version (string) + r.skip_string(MAX_SCHEMA_VERSION_LEN)?; + // 2. asset_id [u8;32] + let asset_id = r.read_array32()?; + // 3a. validator_oit.identifier (string) + r.skip_string(MAX_OIT_IDENTIFIER_LEN)?; + // 3b. validator_oit.level (u8 crudo) + let level = r.read_byte()?; + // 3c. validator_oit.public_key [u8;32] + let public_key = r.read_array32()?; + // 4. validated_at_us: u64 (varint u64, descartado) + let _validated_at_us = r.read_varint_u64()?; + // 5. verdict: enum discriminant (varint u32 estricto) + let verdict = r.read_varint_u32()?; + if verdict > 2 { + return Err(SigningPayloadError::InvalidDiscriminant); + } + // 6. severity: enum discriminant (varint u32 estricto) + let severity = r.read_varint_u32()?; + if severity > 3 { + return Err(SigningPayloadError::InvalidDiscriminant); + } + // 7. constraints_checked: u32 (varint u32 estricto, descartado) + let _constraints_checked = r.read_varint_u32()?; + + // Estricto: no debe sobrar nada. + if r.pos != payload.len() { + return Err(SigningPayloadError::TrailingBytes); + } + + Ok(DecodedSigningPayload { + asset_id, + public_key, + level, + verdict: verdict as u8, + severity: severity as u8, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Golden vector 1 generado desde el oráculo REAL de Ergon + /// (`/tmp/golden-asp3`, keypair seed [7u8;32], asset_id [1u8;32], + /// atelier_canonical level 3, validated_at 1234567890, Passed/Info, 42). + const PAYLOAD1_HEX: &str = "05312e302e300101010101010101010101010101010101010101010101010101010101010101146174656c6965725f63616e6f6e6963616c5f763103ea4a6c63e29c520abef5507b132ec5f9954776aebebe7b92421eea691446d22cd285d8cc0400002a"; + const PUBKEY1_HEX: &str = "ea4a6c63e29c520abef5507b132ec5f9954776aebebe7b92421eea691446d22c"; + + /// Golden vector 4: validador anónimo (identifier "anonymous_canonical", + /// level 0) — gate institucional debe rechazarlo. + const PAYLOAD4_HEX: &str = "05312e302e30040404040404040404040404040404040404040404040404040404040404040413616e6f6e796d6f75735f63616e6f6e6963616c00ea4a6c63e29c520abef5507b132ec5f9954776aebebe7b92421eea691446d22c8080f9c0c1c48203000001"; + + /// Golden vector 3: Failed/Critical — NO anchorable. + const PAYLOAD3_HEX: &str = "05312e302e300303030303030303030303030303030303030303030303030303030303030303146174656c6965725f63616e6f6e6963616c5f763103ea4a6c63e29c520abef5507b132ec5f9954776aebebe7b92421eea691446d22c8080f9c0c1c48203020203"; + + fn unhex(s: &str) -> Vec { + (0..s.len()).step_by(2).map(|i| u8::from_str_radix(&s[i..i + 2], 16).unwrap()).collect() + } + + #[test] + fn decode_golden_vector_1_passed_info_institutional() { + let payload = unhex(PAYLOAD1_HEX); + let d = decode_signing_payload(&payload).expect("decodes"); + assert_eq!(d.asset_id, [1u8; 32], "asset_id byte-fiel del oráculo"); + assert_eq!(d.public_key, unhex(PUBKEY1_HEX).as_slice()); + assert_eq!(d.level, 3); + assert_eq!(d.verdict, 0); // Passed + assert_eq!(d.severity, 0); // Info + assert!(d.is_institutional()); + assert!(d.is_anchorable()); + } + + #[test] + fn decode_golden_vector_4_anonymous_level_0_not_institutional() { + let payload = unhex(PAYLOAD4_HEX); + let d = decode_signing_payload(&payload).expect("decodes"); + assert_eq!(d.asset_id, [4u8; 32]); + assert_eq!(d.level, 0); + assert!(!d.is_institutional(), "anónimo level 0 NO es institucional"); + assert!(d.is_anchorable(), "Passed/Info sí es anchorable (el gate de tier es ortogonal)"); + } + + #[test] + fn decode_golden_vector_3_failed_critical_not_anchorable() { + let payload = unhex(PAYLOAD3_HEX); + let d = decode_signing_payload(&payload).expect("decodes"); + assert_eq!(d.asset_id, [3u8; 32]); + assert_eq!(d.verdict, 2); // Failed + assert_eq!(d.severity, 2); // Critical + assert!(!d.is_anchorable(), "Failed/Critical NO anchorable"); + } + + #[test] + fn signed_message_structure_tag_genesis_payload() { + let payload = unhex(PAYLOAD1_HEX); + let genesis = [0xABu8; 32]; + let msg = attestation_signed_message(&payload, &genesis); + assert_eq!(msg.len(), ATTESTATION_DOMAIN_TAG.len() + 32 + payload.len()); + assert!(msg.starts_with(ATTESTATION_DOMAIN_TAG)); + let after_tag = &msg[ATTESTATION_DOMAIN_TAG.len()..]; + assert_eq!(&after_tag[..32], &genesis); + assert_eq!(&after_tag[32..], &payload[..]); + } + + #[test] + fn signed_message_genesis_binds_anti_replay() { + let payload = unhex(PAYLOAD1_HEX); + let a = attestation_signed_message(&payload, &[0xAAu8; 32]); + let b = attestation_signed_message(&payload, &[0xBBu8; 32]); + assert_ne!(a, b, "distinto genesis ⇒ distinto mensaje (cierra replay cross-chain)"); + } + + #[test] + fn decode_rejects_truncated() { + let payload = unhex(PAYLOAD1_HEX); + // Truncar el último byte (constraints_checked varint) → se acaba antes. + let err = decode_signing_payload(&payload[..payload.len() - 1]).unwrap_err(); + assert_eq!(err, SigningPayloadError::UnexpectedEnd); + } + + #[test] + fn decode_rejects_trailing_bytes() { + let mut payload = unhex(PAYLOAD1_HEX); + payload.push(0x00); + assert_eq!(decode_signing_payload(&payload), Err(SigningPayloadError::TrailingBytes)); + } + + #[test] + fn decode_rejects_oversized_identifier() { + // schema "1.0.0" (ok) + asset_id 32B + identifier con len 200 (> 64 cota). + let mut payload = unhex("05312e302e30"); + payload.extend_from_slice(&[1u8; 32]); + payload.push(0xC8); // varint 200 = 0xC8 0x01 + payload.push(0x01); + assert_eq!(decode_signing_payload(&payload), Err(SigningPayloadError::LengthTooLarge)); + } + + #[test] + fn decode_rejects_empty() { + assert_eq!(decode_signing_payload(&[]), Err(SigningPayloadError::UnexpectedEnd)); + } + + #[test] + fn decode_rejects_overlong_u32_varint_for_verdict() { + // Fidelidad postcard: el verdict (enum discriminant u32) NO admite + // varint sobre-largo. Construyo P1 reemplazando el verdict (1 byte 0x00, + // penúltimo del payload antes de severity+constraints) por un varint de + // 6 bytes que postcard::from_bytes rechazaría. Reconstruyo el prefijo + // hasta verdict y le añado el verdict sobre-largo + severity + constraints. + // schema(6) + asset(32) + identifier(21) + level(1) + pubkey(32) + ts(5) + let mut p = unhex("05312e302e30"); // schema "1.0.0" + p.extend_from_slice(&[1u8; 32]); // asset_id + p.push(0x14); // identifier len 20 + p.extend_from_slice(b"atelier_canonical_v1"); + p.push(0x03); // level + p.extend_from_slice(&unhex("ea4a6c63e29c520abef5507b132ec5f9954776aebebe7b92421eea691446d22c")); + p.extend_from_slice(&unhex("d285d8cc04")); // validated_at_us varint + // verdict = 0 pero codificado sobre-largo en 6 bytes (5º byte presente). + p.extend_from_slice(&[0x80, 0x80, 0x80, 0x80, 0x80, 0x00]); + p.push(0x00); // severity Info + p.push(0x2a); // constraints 42 + assert_eq!(decode_signing_payload(&p), Err(SigningPayloadError::VarintOverflow)); + } +} diff --git a/crates/origin-chain-types/src/lib.rs b/crates/origin-chain-types/src/lib.rs index 0513a1a..b51c52a 100644 --- a/crates/origin-chain-types/src/lib.rs +++ b/crates/origin-chain-types/src/lib.rs @@ -43,6 +43,9 @@ pub mod accounts; pub mod actions; pub mod assets; pub mod attestation; +/// FIX-4: verificador de attestation on-chain — mensaje domain-separado + +/// decoder postcard secuencial del SigningPayload de Ergon (espejo ASP-3). +pub mod attestation_verify; pub mod errors; pub mod fees; pub mod governance; @@ -58,6 +61,10 @@ pub use assets::AssetHash; pub use attestation::{ Blake3Hash, OitIdentity, SeverityTier, ValidationAttestation, ValidationVerdict, }; +pub use attestation_verify::{ + attestation_signed_message, decode_signing_payload, DecodedSigningPayload, + SigningPayloadError, ATTESTATION_DOMAIN_TAG, +}; pub use errors::ChainError; pub use fees::{FeeCalculator, OperationKind}; pub use governance::{GovernanceProposal, ProposalId, Vote, VoteStance};