diff --git a/Cargo.lock b/Cargo.lock index 57eab1b..cb340e1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -80,6 +80,24 @@ dependencies = [ "tokio", ] +[[package]] +name = "agorapura-core" +version = "0.1.0" +dependencies = [ + "blake3", + "ed25519-dalek", + "serde", + "thiserror 2.0.18", +] + +[[package]] +name = "agorapura-graph" +version = "0.1.0" +dependencies = [ + "agorapura-core", + "serde", +] + [[package]] name = "ahash" version = "0.7.8" diff --git a/Cargo.toml b/Cargo.toml index 5038407..95bf776 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -119,6 +119,12 @@ members = [ "crates/modules/verbo/verbo-mock", "crates/modules/verbo/verbo-daemon", + # ============================================================ + # modules/agorapura/ — Identidad humana federada + # ============================================================ + "crates/modules/agorapura/agorapura-core", + "crates/modules/agorapura/agorapura-graph", + # ============================================================ # modules/nakui/ — ERP matemático (categórico) # ============================================================ diff --git a/crates/modules/agorapura/SDD.md b/crates/modules/agorapura/SDD.md new file mode 100644 index 0000000..fe12f70 --- /dev/null +++ b/crates/modules/agorapura/SDD.md @@ -0,0 +1,57 @@ +# modules/agorapura/ — Identidad humana federada + +**Propósito.** Un ágora de identidad sin autoridad central. Cada +identidad —persona, comunidad, alianza, institución— es una clave +pública; cada afirmación sobre ella, un claim; cada respaldo, una +atestación firmada que viaja con su propia prueba. La verdad no la dicta +un servidor: emerge de quién atestigua qué, ponderado por la política +que negocie quien lee. + +## Crates + +| crate | tipo | rol | +| ----------------- | ---- | ------------------------------------------------------------ | +| `agorapura-core` | lib | `Identity`/`Keypair` (ed25519), `Claim`, `Attestation` firmada y autoverificable | +| `agorapura-graph` | lib | `TrustGraph` (atestaciones verificadas) + `Corroboration` + `TrustPolicy` negociada | + +## Modelo + +```text + Keypair ──► Identity (kind: Person|Community|Alliance|Institution) + │ + Claim (subject · predicate = value) ──firmado──► Attestation + │ │ + └──────────► TrustGraph ◄──────────────┘ + │ + corroboration(claim) → Corroboration + │ + TrustPolicy.accepts() → sí / no +``` + +- **Fractal**: persona, comunidad, alianza e institución comparten + estructura idéntica. Que una institución atestigüe sobre una persona + o una alianza sobre una comunidad es la misma operación. +- **Autoverificable**: una `Attestation` lleva la clave pública del + atestador y su firma — cualquiera la valida sin consultar a nadie. +- **Sin veredicto central**: el grafo devuelve evidencia cruda + (`Corroboration`); la validez la decide una `TrustPolicy` *negociada*. + Dos consumidores con políticas distintas pueden discrepar legítimamente + sobre la misma red. + +## Dependencias + +- `core` ← `ed25519-dalek`, `blake3` (id = BLAKE3 de la clave pública). +- `graph` ← `agorapura-core`. Ambos `#![forbid(unsafe_code)]`. +- Cero red, cero estado global — tipos puros. El transporte y el + descubrimiento (DHT, Cards) van en capas superiores. + +## Determinismo + +`Keypair::from_seed` es determinista — tests y derivación jerárquica de +claves dependen de ello. Una identidad real siembra desde un CSPRNG. + +## Estado + +`core` + `graph` implementados y verdes (22 tests). **Pendiente**: +integración con `CardKind` (variantes Person/Community/Alliance/ +Institution en el protocolo) y descubrimiento federado vía DHT. diff --git a/crates/modules/agorapura/agorapura-core/Cargo.toml b/crates/modules/agorapura/agorapura-core/Cargo.toml new file mode 100644 index 0000000..c28d390 --- /dev/null +++ b/crates/modules/agorapura/agorapura-core/Cargo.toml @@ -0,0 +1,14 @@ +[package] +name = "agorapura-core" +version.workspace = true +edition.workspace = true +license.workspace = true +authors.workspace = true +publish.workspace = true +description = "agorapura — núcleo de identidad federada: identidades fractales (persona/comunidad/alianza/institución), claims y atestaciones firmadas ed25519." + +[dependencies] +ed25519-dalek = { workspace = true } +blake3 = { workspace = true } +serde = { workspace = true } +thiserror = { workspace = true } diff --git a/crates/modules/agorapura/agorapura-core/src/attest.rs b/crates/modules/agorapura/agorapura-core/src/attest.rs new file mode 100644 index 0000000..a2937fa --- /dev/null +++ b/crates/modules/agorapura/agorapura-core/src/attest.rs @@ -0,0 +1,131 @@ +//! Atestaciones — un claim respaldado por la firma de una identidad. +//! +//! La atestación es la unidad de confianza de agorapura: «la institución +//! *Venezuela* atestigua que el claim *nacionalidad = venezolana* sobre +//! *Yumaira* es cierto». Cualquiera puede verificar la firma sin +//! consultar a nadie — la prueba viaja con el dato. + +use serde::{Deserialize, Serialize}; + +use crate::claim::Claim; +use crate::identity::{verify_signature, IdentityId, Keypair}; +use crate::AgoraError; + +/// Adaptador serde para `[u8; 64]` — serde sólo cubre arrays hasta 32, +/// así que la firma viaja como secuencia y se revalida su largo al leer. +mod sig_serde { + use serde::{Deserialize, Deserializer, Serialize, Serializer}; + + pub fn serialize(sig: &[u8; 64], s: S) -> Result { + sig.as_slice().serialize(s) + } + + pub fn deserialize<'de, D: Deserializer<'de>>(d: D) -> Result<[u8; 64], D::Error> { + let v = Vec::::deserialize(d)?; + v.try_into() + .map_err(|_| serde::de::Error::custom("la firma debe ser de 64 bytes")) + } +} + +/// Un claim firmado por un atestador. Autoverificable y autónomo. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct Attestation { + /// El claim respaldado. + pub claim: Claim, + /// Identidad que firma — derivada de `attester_key`. + pub attester: IdentityId, + /// Clave pública del atestador (para verificar sin un directorio). + pub attester_key: [u8; 32], + /// Firma ed25519 sobre `claim.canonical_bytes()`. + #[serde(with = "sig_serde")] + pub signature: [u8; 64], +} + +impl Attestation { + /// Crea una atestación firmando `claim` con `keypair`. + pub fn create(keypair: &Keypair, claim: Claim) -> Self { + let signature = keypair.sign(&claim.canonical_bytes()); + Self { + claim, + attester: keypair.identity_id(), + attester_key: keypair.public_key(), + signature, + } + } + + /// Verifica la atestación. Comprueba dos cosas: + /// 1. la firma cubre el claim bajo `attester_key`; + /// 2. `attester` coincide con el id derivado de `attester_key` + /// (nadie puede atribuir su firma a otra identidad). + pub fn verify(&self) -> Result<(), AgoraError> { + if self.attester != IdentityId::from_public_key(&self.attester_key) { + return Err(AgoraError::AttesterMismatch); + } + verify_signature(&self.attester_key, &self.claim.canonical_bytes(), &self.signature) + } + + /// `true` si la atestación es de un atestador hablando de sí mismo. + /// Una identidad puede declararse cosas, pero esa evidencia vale + /// distinto que la de un tercero — quien evalúa decide cuánto. + pub fn is_self_attested(&self) -> bool { + self.attester == self.claim.subject + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::identity::IdentityKind; + + #[test] + fn created_attestation_verifies() { + let venezuela = Keypair::from_seed([10; 32]); + let yumaira = Keypair::from_seed([20; 32]); + let claim = Claim::new(yumaira.identity_id(), "nacionalidad", "venezolana", 1_700_000_000); + let att = Attestation::create(&venezuela, claim); + assert!(att.verify().is_ok()); + assert_eq!(att.attester, venezuela.identity_id()); + } + + #[test] + fn tampered_claim_fails_verification() { + let venezuela = Keypair::from_seed([10; 32]); + let yumaira = Keypair::from_seed([20; 32]); + let claim = Claim::new(yumaira.identity_id(), "nacionalidad", "venezolana", 1_700_000_000); + let mut att = Attestation::create(&venezuela, claim); + // Alterar el valor invalida la firma. + att.claim.value = "marciana".into(); + assert!(matches!(att.verify(), Err(AgoraError::BadSignature))); + } + + #[test] + fn spoofed_attester_is_rejected() { + let real = Keypair::from_seed([10; 32]); + let impostor = Keypair::from_seed([99; 32]); + let yumaira = Keypair::from_seed([20; 32]); + let claim = Claim::new(yumaira.identity_id(), "habilidad", "soldadura", 0); + let mut att = Attestation::create(&real, claim); + // Reatribuir la atestación a otra identidad la rompe. + att.attester = impostor.identity_id(); + assert!(matches!(att.verify(), Err(AgoraError::AttesterMismatch))); + } + + #[test] + fn self_attestation_is_flagged() { + let yumaira = Keypair::from_seed([20; 32]); + let claim = Claim::new(yumaira.identity_id(), "habilidad", "carpintería", 0); + let att = Attestation::create(&yumaira, claim); + assert!(att.verify().is_ok()); + assert!(att.is_self_attested()); + } + + #[test] + fn third_party_attestation_is_not_self() { + let comunidad = Keypair::from_seed([30; 32]); + let _ = comunidad.identity(IdentityKind::Community, "Vecinos del Valle"); + let yumaira = Keypair::from_seed([20; 32]); + let claim = Claim::new(yumaira.identity_id(), "miembro-de", "Vecinos del Valle", 0); + let att = Attestation::create(&comunidad, claim); + assert!(!att.is_self_attested()); + } +} diff --git a/crates/modules/agorapura/agorapura-core/src/claim.rs b/crates/modules/agorapura/agorapura-core/src/claim.rs new file mode 100644 index 0000000..5f66108 --- /dev/null +++ b/crates/modules/agorapura/agorapura-core/src/claim.rs @@ -0,0 +1,87 @@ +//! Claims — afirmaciones sobre una identidad. +//! +//! Un [`Claim`] es una tripleta sujeto–predicado–valor: *«de Yumaira +//! (sujeto), su nacionalidad (predicado) es venezolana (valor)»*. El +//! claim por sí solo no afirma nada — sólo cuando alguien lo firma +//! ([`crate::Attestation`]) adquiere peso. + +use serde::{Deserialize, Serialize}; + +use crate::identity::IdentityId; + +/// Una afirmación sobre una identidad. Inerte hasta que una atestación +/// la respalda. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct Claim { + /// La identidad de la que trata el claim. + pub subject: IdentityId, + /// Qué se afirma — `"nacionalidad"`, `"miembro-de"`, `"habilidad"`. + pub predicate: String, + /// El valor afirmado — `"venezolana"`, el id de una comunidad, etc. + pub value: String, + /// Segundos Unix en que se emitió el claim. + pub issued_at: u64, +} + +impl Claim { + /// Construye un claim. + pub fn new( + subject: IdentityId, + predicate: impl Into, + value: impl Into, + issued_at: u64, + ) -> Self { + Self { + subject, + predicate: predicate.into(), + value: value.into(), + issued_at, + } + } + + /// Serialización canónica determinista — el mensaje exacto que se + /// firma. Cada campo va con prefijo de largo para que no haya + /// ambigüedad de fronteras entre `predicate` y `value`. + pub fn canonical_bytes(&self) -> Vec { + let mut out = Vec::with_capacity(64 + self.predicate.len() + self.value.len()); + out.extend_from_slice(b"agorapura-claim\x01"); + out.extend_from_slice(self.subject.as_bytes()); + out.extend_from_slice(&self.issued_at.to_le_bytes()); + out.extend_from_slice(&(self.predicate.len() as u64).to_le_bytes()); + out.extend_from_slice(self.predicate.as_bytes()); + out.extend_from_slice(&(self.value.len() as u64).to_le_bytes()); + out.extend_from_slice(self.value.as_bytes()); + out + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::identity::Keypair; + + fn subject() -> IdentityId { + Keypair::from_seed([9; 32]).identity_id() + } + + #[test] + fn canonical_bytes_are_deterministic() { + let c = Claim::new(subject(), "nacionalidad", "venezolana", 1_700_000_000); + assert_eq!(c.canonical_bytes(), c.clone().canonical_bytes()); + } + + #[test] + fn distinct_claims_have_distinct_canonical_bytes() { + let a = Claim::new(subject(), "nacionalidad", "venezolana", 1_700_000_000); + let b = Claim::new(subject(), "nacionalidad", "colombiana", 1_700_000_000); + assert_ne!(a.canonical_bytes(), b.canonical_bytes()); + } + + #[test] + fn field_boundaries_are_unambiguous() { + // Sin prefijo de largo, "ab"+"c" colisionaría con "a"+"bc". + let a = Claim::new(subject(), "ab", "c", 0); + let b = Claim::new(subject(), "a", "bc", 0); + assert_ne!(a.canonical_bytes(), b.canonical_bytes()); + } +} diff --git a/crates/modules/agorapura/agorapura-core/src/identity.rs b/crates/modules/agorapura/agorapura-core/src/identity.rs new file mode 100644 index 0000000..5b505f5 --- /dev/null +++ b/crates/modules/agorapura/agorapura-core/src/identity.rs @@ -0,0 +1,180 @@ +//! Identidades fractales — y el par de claves que las firma. +//! +//! Una persona, una comunidad, una alianza y una institución comparten +//! exactamente la misma estructura: una clave pública, un tipo y un +//! nombre. La identidad es *fractal* — autosemejante en cada escala. Que +//! una comunidad atestigüe sobre una persona, o una institución sobre +//! una comunidad, no es un caso especial: es la misma operación. + +use serde::{Deserialize, Serialize}; +use std::fmt; + +use crate::AgoraError; + +/// Naturaleza de una identidad. No cambia su estructura — sólo informa +/// a quien la lee a qué escala social pertenece. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum IdentityKind { + /// Un ser humano individual. + Person, + /// Una agrupación de identidades con un propósito común. + Community, + /// Una federación de comunidades. + Alliance, + /// Una entidad formal y persistente (un Estado, una universidad). + Institution, +} + +/// Identificador estable de una identidad: BLAKE3 de su clave pública. +/// Inmutable mientras la clave no cambie. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub struct IdentityId([u8; 32]); + +impl IdentityId { + /// Deriva el id desde una clave pública ed25519. + pub fn from_public_key(public_key: &[u8; 32]) -> Self { + Self(*blake3::hash(public_key).as_bytes()) + } + + /// Bytes crudos del identificador. + pub fn as_bytes(&self) -> &[u8; 32] { + &self.0 + } +} + +impl fmt::Display for IdentityId { + /// Prefijo hex abreviado — suficiente para distinguir identidades. + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + for b in &self.0[..6] { + write!(f, "{b:02x}")?; + } + write!(f, "…") + } +} + +/// La cara pública de una identidad: lo que se publica y se comparte. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct Identity { + pub kind: IdentityKind, + /// Clave pública ed25519. + pub public_key: [u8; 32], + /// Nombre legible — no es único ni autoritativo, sólo presentación. + pub display_name: String, +} + +impl Identity { + /// Identificador derivado de la clave pública. + pub fn id(&self) -> IdentityId { + IdentityId::from_public_key(&self.public_key) + } +} + +/// El par de claves de una identidad. La clave privada vive **sólo** en +/// poder de su dueño; nunca se serializa ni viaja por la red. +pub struct Keypair { + signing: ed25519_dalek::SigningKey, +} + +impl Keypair { + /// Construye un par determinista desde una semilla de 32 bytes. + /// + /// La reproducibilidad es deliberada: tests y derivación jerárquica + /// de claves dependen de ella. Para una identidad real, la semilla + /// debe venir de un CSPRNG. + pub fn from_seed(seed: [u8; 32]) -> Self { + Self { signing: ed25519_dalek::SigningKey::from_bytes(&seed) } + } + + /// Clave pública (32 bytes) de este par. + pub fn public_key(&self) -> [u8; 32] { + self.signing.verifying_key().to_bytes() + } + + /// Identificador de la identidad de este par. + pub fn identity_id(&self) -> IdentityId { + IdentityId::from_public_key(&self.public_key()) + } + + /// Arma la `Identity` pública correspondiente. + pub fn identity(&self, kind: IdentityKind, display_name: impl Into) -> Identity { + Identity { + kind, + public_key: self.public_key(), + display_name: display_name.into(), + } + } + + /// Firma un mensaje arbitrario, devolviendo los 64 bytes de la firma. + pub fn sign(&self, message: &[u8]) -> [u8; 64] { + use ed25519_dalek::Signer; + self.signing.sign(message).to_bytes() + } +} + +/// Verifica una firma ed25519 contra una clave pública y un mensaje. +pub fn verify_signature( + public_key: &[u8; 32], + message: &[u8], + signature: &[u8; 64], +) -> Result<(), AgoraError> { + use ed25519_dalek::Verifier; + let vk = ed25519_dalek::VerifyingKey::from_bytes(public_key) + .map_err(|_| AgoraError::BadPublicKey)?; + let sig = ed25519_dalek::Signature::from_bytes(signature); + vk.verify(message, &sig).map_err(|_| AgoraError::BadSignature) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn id_is_stable_for_a_key() { + let kp = Keypair::from_seed([7; 32]); + let a = kp.identity(IdentityKind::Person, "Yumaira"); + let b = kp.identity(IdentityKind::Person, "otro nombre"); + // El id depende de la clave, no del nombre. + assert_eq!(a.id(), b.id()); + } + + #[test] + fn different_seeds_yield_different_identities() { + let a = Keypair::from_seed([1; 32]).identity_id(); + let b = Keypair::from_seed([2; 32]).identity_id(); + assert_ne!(a, b); + } + + #[test] + fn signature_roundtrips() { + let kp = Keypair::from_seed([42; 32]); + let sig = kp.sign(b"mensaje de prueba"); + assert!(verify_signature(&kp.public_key(), b"mensaje de prueba", &sig).is_ok()); + } + + #[test] + fn tampered_message_fails_verification() { + let kp = Keypair::from_seed([42; 32]); + let sig = kp.sign(b"original"); + assert!(matches!( + verify_signature(&kp.public_key(), b"manipulado", &sig), + Err(AgoraError::BadSignature) + )); + } + + #[test] + fn wrong_key_fails_verification() { + let signer = Keypair::from_seed([1; 32]); + let other = Keypair::from_seed([2; 32]); + let sig = signer.sign(b"msg"); + assert!(verify_signature(&other.public_key(), b"msg", &sig).is_err()); + } + + #[test] + fn id_display_is_abbreviated_hex() { + let id = Keypair::from_seed([0; 32]).identity_id(); + let s = id.to_string(); + // 6 bytes → 12 dígitos hex, más el elipsis. + assert!(s.ends_with('…') && s.chars().count() == 13); + } +} diff --git a/crates/modules/agorapura/agorapura-core/src/lib.rs b/crates/modules/agorapura/agorapura-core/src/lib.rs new file mode 100644 index 0000000..21e7326 --- /dev/null +++ b/crates/modules/agorapura/agorapura-core/src/lib.rs @@ -0,0 +1,36 @@ +//! `agorapura-core` — identidad humana federada, sin autoridad central. +//! +//! El ágora no tiene un registro maestro. Cada identidad —persona, +//! comunidad, alianza, institución— es una clave pública; cada afirmación +//! sobre ella es un [`Claim`]; cada respaldo es una [`Attestation`] +//! firmada que viaja con su propia prueba. La verdad no la dicta un +//! servidor: emerge de quién atestigua qué, y de cuánto peso le da a +//! cada atestador quien la lee. +//! +//! - [`identity`] — identidades fractales + claves ed25519. +//! - [`claim`] — afirmaciones sujeto–predicado–valor. +//! - [`attest`] — claims firmados, autoverificables. +//! +//! Cero estado global, cero red: tipos puros. La red de confianza vive +//! en `agorapura-graph`; el transporte, en capas superiores. + +#![forbid(unsafe_code)] + +pub mod attest; +pub mod claim; +pub mod identity; + +pub use attest::Attestation; +pub use claim::Claim; +pub use identity::{verify_signature, Identity, IdentityId, IdentityKind, Keypair}; + +/// Falla de una operación de identidad o atestación. +#[derive(Debug, thiserror::Error, PartialEq, Eq)] +pub enum AgoraError { + #[error("clave pública ed25519 inválida")] + BadPublicKey, + #[error("firma inválida: no corresponde al mensaje y la clave")] + BadSignature, + #[error("el atestador declarado no corresponde a su clave pública")] + AttesterMismatch, +} diff --git a/crates/modules/agorapura/agorapura-graph/Cargo.toml b/crates/modules/agorapura/agorapura-graph/Cargo.toml new file mode 100644 index 0000000..c060f86 --- /dev/null +++ b/crates/modules/agorapura/agorapura-graph/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "agorapura-graph" +version.workspace = true +edition.workspace = true +license.workspace = true +authors.workspace = true +publish.workspace = true +description = "agorapura — red de confianza: almacena atestaciones verificadas y responde corroboración de claims; la validez la decide una política negociada, no el grafo." + +[dependencies] +agorapura-core = { path = "../agorapura-core" } +serde = { workspace = true } diff --git a/crates/modules/agorapura/agorapura-graph/src/lib.rs b/crates/modules/agorapura/agorapura-graph/src/lib.rs new file mode 100644 index 0000000..e0d939b --- /dev/null +++ b/crates/modules/agorapura/agorapura-graph/src/lib.rs @@ -0,0 +1,306 @@ +//! `agorapura-graph` — la red de confianza del ágora. +//! +//! Acumula [`Attestation`]s **verificadas** (una atestación con firma +//! rota nunca entra) y responde preguntas de corroboración: *¿quién +//! respalda este claim?* +//! +//! El grafo deliberadamente **no** emite un veredicto. La verdad del +//! ágora no es absoluta: depende de cuánto peso le dé a cada atestador +//! quien la consulta. Por eso [`TrustGraph::corroboration`] devuelve la +//! evidencia cruda y [`TrustPolicy`] —un umbral *negociado*— la traduce +//! a un sí/no. Dos consumidores con políticas distintas pueden mirar la +//! misma red y discrepar legítimamente. + +#![forbid(unsafe_code)] + +use std::collections::HashMap; + +use agorapura_core::{AgoraError, Attestation, Identity, IdentityId}; +use serde::{Deserialize, Serialize}; + +/// Evidencia acumulada a favor de un claim concreto. +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct Corroboration { + /// Atestadores distintos que respaldan exactamente este claim. + pub attesters: Vec, + /// El propio sujeto figura entre los atestadores. + pub self_attested: bool, +} + +impl Corroboration { + /// `true` si nadie respalda el claim. + pub fn is_empty(&self) -> bool { + self.attesters.is_empty() + } + + /// Atestadores totales (incluido el sujeto si se auto-atestó). + pub fn total(&self) -> usize { + self.attesters.len() + } + + /// Atestadores que no son el sujeto — la evidencia independiente. + pub fn third_party(&self) -> usize { + self.total() - usize::from(self.self_attested) + } +} + +/// Umbral *negociado* de aceptación de un claim. No es una verdad del +/// sistema: cada consumidor adopta el suyo según lo que pacte. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub struct TrustPolicy { + /// Atestadores terceros distintos exigidos como mínimo. + pub min_third_party: usize, + /// Si la auto-atestación del sujeto cuenta como respaldo válido. + pub accept_self: bool, +} + +impl TrustPolicy { + /// Política estricta: al menos `n` terceros, la auto-atestación no + /// suma. + pub fn strict(min_third_party: usize) -> Self { + Self { min_third_party, accept_self: false } + } + + /// `true` si la evidencia satisface la política. + pub fn accepts(&self, c: &Corroboration) -> bool { + if c.third_party() >= self.min_third_party { + return true; + } + self.accept_self && c.self_attested && self.min_third_party == 0 + } +} + +impl Default for TrustPolicy { + /// Por defecto: un tercero basta, la auto-atestación no. + fn default() -> Self { + Self::strict(1) + } +} + +/// La red de confianza: identidades conocidas + atestaciones verificadas. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct TrustGraph { + identities: HashMap, + /// Atestaciones verificadas, en orden de inserción. + attestations: Vec, +} + +impl TrustGraph { + pub fn new() -> Self { + Self::default() + } + + /// Registra (o actualiza) una identidad conocida. + pub fn register(&mut self, identity: Identity) { + self.identities.insert(identity.id(), identity); + } + + /// Identidad registrada con ese id, si la hay. + pub fn identity(&self, id: IdentityId) -> Option<&Identity> { + self.identities.get(&id) + } + + /// Cantidad de identidades registradas. + pub fn identity_count(&self) -> usize { + self.identities.len() + } + + /// Cantidad de atestaciones verificadas almacenadas. + pub fn attestation_count(&self) -> usize { + self.attestations.len() + } + + /// Verifica una atestación y, si es válida y no es duplicada exacta, + /// la incorpora. Una firma rota se rechaza con error — la red sólo + /// guarda evidencia comprobable. + pub fn add_attestation(&mut self, att: Attestation) -> Result<(), AgoraError> { + att.verify()?; + if !self.attestations.contains(&att) { + self.attestations.push(att); + } + Ok(()) + } + + /// Atestaciones cuyo claim trata sobre `subject`. + pub fn attestations_about(&self, subject: IdentityId) -> Vec<&Attestation> { + self.attestations + .iter() + .filter(|a| a.claim.subject == subject) + .collect() + } + + /// Atestaciones emitidas por `attester`. + pub fn attestations_by(&self, attester: IdentityId) -> Vec<&Attestation> { + self.attestations + .iter() + .filter(|a| a.attester == attester) + .collect() + } + + /// Atestaciones que respaldan exactamente el claim + /// `subject · predicate = value` (la marca de tiempo no importa). + pub fn evidence_for( + &self, + subject: IdentityId, + predicate: &str, + value: &str, + ) -> Vec<&Attestation> { + self.attestations + .iter() + .filter(|a| { + a.claim.subject == subject + && a.claim.predicate == predicate + && a.claim.value == value + }) + .collect() + } + + /// Resume la corroboración de un claim: atestadores distintos y si + /// el sujeto se lo auto-atestó. El veredicto lo pone una + /// [`TrustPolicy`]. + pub fn corroboration( + &self, + subject: IdentityId, + predicate: &str, + value: &str, + ) -> Corroboration { + let mut attesters: Vec = Vec::new(); + let mut self_attested = false; + for att in self.evidence_for(subject, predicate, value) { + if att.is_self_attested() { + self_attested = true; + } + if !attesters.contains(&att.attester) { + attesters.push(att.attester); + } + } + Corroboration { attesters, self_attested } + } + + /// Atajo: `true` si la `policy` acepta el claim dada la evidencia. + pub fn is_accepted( + &self, + subject: IdentityId, + predicate: &str, + value: &str, + policy: &TrustPolicy, + ) -> bool { + policy.accepts(&self.corroboration(subject, predicate, value)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use agorapura_core::{Attestation, Claim, IdentityKind, Keypair}; + + /// Mundo de prueba: Yumaira (persona) + tres atestadores. + fn actors() -> (Keypair, Keypair, Keypair, Keypair) { + ( + Keypair::from_seed([20; 32]), // yumaira + Keypair::from_seed([10; 32]), // venezuela (institución) + Keypair::from_seed([30; 32]), // comunidad + Keypair::from_seed([40; 32]), // vecina + ) + } + + fn attest(by: &Keypair, subject: IdentityId, pred: &str, val: &str) -> Attestation { + Attestation::create(by, Claim::new(subject, pred, val, 1_700_000_000)) + } + + #[test] + fn rejects_attestation_with_broken_signature() { + let (yumaira, venezuela, ..) = actors(); + let mut att = attest(&venezuela, yumaira.identity_id(), "nacionalidad", "venezolana"); + att.claim.value = "falsa".into(); // rompe la firma + let mut g = TrustGraph::new(); + assert!(g.add_attestation(att).is_err()); + assert_eq!(g.attestation_count(), 0); + } + + #[test] + fn stores_and_queries_verified_attestations() { + let (yumaira, venezuela, comunidad, _) = actors(); + let mut g = TrustGraph::new(); + g.register(venezuela.identity(IdentityKind::Institution, "Venezuela")); + g.add_attestation(attest(&venezuela, yumaira.identity_id(), "nacionalidad", "venezolana")) + .unwrap(); + g.add_attestation(attest(&comunidad, yumaira.identity_id(), "miembro-de", "Valle")) + .unwrap(); + assert_eq!(g.attestations_about(yumaira.identity_id()).len(), 2); + assert_eq!(g.attestations_by(venezuela.identity_id()).len(), 1); + assert_eq!(g.identity_count(), 1); + } + + #[test] + fn duplicate_attestation_is_ignored() { + let (yumaira, venezuela, ..) = actors(); + let att = attest(&venezuela, yumaira.identity_id(), "nacionalidad", "venezolana"); + let mut g = TrustGraph::new(); + g.add_attestation(att.clone()).unwrap(); + g.add_attestation(att).unwrap(); + assert_eq!(g.attestation_count(), 1); + } + + #[test] + fn corroboration_counts_distinct_attesters() { + let (yumaira, venezuela, comunidad, vecina) = actors(); + let mut g = TrustGraph::new(); + for who in [&venezuela, &comunidad, &vecina] { + g.add_attestation(attest(who, yumaira.identity_id(), "nacionalidad", "venezolana")) + .unwrap(); + } + let c = g.corroboration(yumaira.identity_id(), "nacionalidad", "venezolana"); + assert_eq!(c.total(), 3); + assert_eq!(c.third_party(), 3); + assert!(!c.self_attested); + } + + #[test] + fn self_attestation_is_distinguished_from_third_party() { + let (yumaira, venezuela, ..) = actors(); + let mut g = TrustGraph::new(); + g.add_attestation(attest(&yumaira, yumaira.identity_id(), "habilidad", "soldadura")) + .unwrap(); + g.add_attestation(attest(&venezuela, yumaira.identity_id(), "habilidad", "soldadura")) + .unwrap(); + let c = g.corroboration(yumaira.identity_id(), "habilidad", "soldadura"); + assert_eq!(c.total(), 2); + assert_eq!(c.third_party(), 1); + assert!(c.self_attested); + } + + #[test] + fn negotiated_policy_decides_acceptance() { + let (yumaira, venezuela, comunidad, _) = actors(); + let mut g = TrustGraph::new(); + g.add_attestation(attest(&venezuela, yumaira.identity_id(), "oficio", "partera")) + .unwrap(); + g.add_attestation(attest(&comunidad, yumaira.identity_id(), "oficio", "partera")) + .unwrap(); + let (sub, pred, val) = (yumaira.identity_id(), "oficio", "partera"); + // Dos terceros: una política laxa acepta, una exigente no. + assert!(g.is_accepted(sub, pred, val, &TrustPolicy::strict(2))); + assert!(!g.is_accepted(sub, pred, val, &TrustPolicy::strict(3))); + } + + #[test] + fn empty_corroboration_for_unknown_claim() { + let (yumaira, ..) = actors(); + let g = TrustGraph::new(); + let c = g.corroboration(yumaira.identity_id(), "nada", "nada"); + assert!(c.is_empty() && c.third_party() == 0); + } + + #[test] + fn policy_can_accept_self_attestation_when_negotiated() { + let (yumaira, ..) = actors(); + let mut g = TrustGraph::new(); + g.add_attestation(attest(&yumaira, yumaira.identity_id(), "lema", "sembrar")) + .unwrap(); + let lax = TrustPolicy { min_third_party: 0, accept_self: true }; + assert!(g.is_accepted(yumaira.identity_id(), "lema", "sembrar", &lax)); + // La política por defecto, sin terceros, no la acepta. + assert!(!g.is_accepted(yumaira.identity_id(), "lema", "sembrar", &TrustPolicy::default())); + } +}