feat(agorapura): identidad humana federada — core + grafo de confianza
agorapura-core: identidades fractales (persona/comunidad/alianza/ institución) sobre claves ed25519, Claims sujeto-predicado-valor y Attestations firmadas y autoverificables (la prueba viaja con el dato). agorapura-graph: TrustGraph guarda sólo atestaciones con firma válida; corroboration() devuelve evidencia cruda y TrustPolicy —un umbral negociado, no una verdad del sistema— la traduce a sí/no. 22 tests. Cero red, cero estado global, #![forbid(unsafe_code)]. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Generated
+18
@@ -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"
|
||||
|
||||
@@ -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)
|
||||
# ============================================================
|
||||
|
||||
@@ -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.
|
||||
@@ -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 }
|
||||
@@ -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<S: Serializer>(sig: &[u8; 64], s: S) -> Result<S::Ok, S::Error> {
|
||||
sig.as_slice().serialize(s)
|
||||
}
|
||||
|
||||
pub fn deserialize<'de, D: Deserializer<'de>>(d: D) -> Result<[u8; 64], D::Error> {
|
||||
let v = Vec::<u8>::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());
|
||||
}
|
||||
}
|
||||
@@ -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<String>,
|
||||
value: impl Into<String>,
|
||||
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<u8> {
|
||||
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());
|
||||
}
|
||||
}
|
||||
@@ -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<String>) -> 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);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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<IdentityId>,
|
||||
/// 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<IdentityId, Identity>,
|
||||
/// Atestaciones verificadas, en orden de inserción.
|
||||
attestations: Vec<Attestation>,
|
||||
}
|
||||
|
||||
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<IdentityId> = 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()));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user