From 4cf53f259f1d89d9edf11cc48642391b521d66f5 Mon Sep 17 00:00:00 2001 From: vjrj Date: Fri, 10 Jul 2026 21:01:11 +0200 Subject: [PATCH] feat(trust): full Duniter web-of-trust membership (params + referents) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Slice 4 of Block 2. The pure rule was already parameterised; this adds the policy, cold-start and UI around it. - commons_core: WotParams (sigQty/stepMax/sigValidity, Duniter defaults) + WebOfTrust.membersWith; npubToHex helper (NIP-19 decode) for adding roots. - WotSettings (keystore): the active parameters, configurable — a young network loosens them and tightens as it grows, as Ğ1 did. Defaults Duniter. - TrustReferents: the bootstrap 'seeds' membership is measured from — a bundled asset (empty until real founders are curated, no invented keys) unioned with referents the user adds by npub/QR. Honest cold-start. - TrustCubit: computes the full membership verdict against referents+params alongside the personal circle, and exposes a TrustTier (networkMember > inYourCircle > vouched > unknown). Certifications issued with the active validity (they expire and renew, Duniter rule). - UI: chat trust badge by tier; a 'Network of trust' screen (manage roots + advanced params) reached from the profile. i18n en/es/pt/ast. Tests: WotParams/membersWith, npubToHex, TrustReferents, WotSettings, and TrustCubit tiers/membership. Resolves the WoT-parameters decision (open-decisions §B). Trust net stays empty/undetermined until seeded — by design; users bootstrap their own roots. --- apps/app_seeds/assets/trust/referents.json | 5 + apps/app_seeds/lib/app.dart | 25 ++ apps/app_seeds/lib/bootstrap.dart | 4 + apps/app_seeds/lib/di/injector.dart | 5 + apps/app_seeds/lib/i18n/ast.i18n.json | 24 +- apps/app_seeds/lib/i18n/en.i18n.json | 24 +- apps/app_seeds/lib/i18n/es.i18n.json | 24 +- apps/app_seeds/lib/i18n/pt.i18n.json | 24 +- apps/app_seeds/lib/i18n/strings.g.dart | 4 +- apps/app_seeds/lib/i18n/strings_ast.g.dart | 50 ++++ apps/app_seeds/lib/i18n/strings_en.g.dart | 90 +++++++ apps/app_seeds/lib/i18n/strings_es.g.dart | 50 ++++ apps/app_seeds/lib/i18n/strings_pt.g.dart | 50 ++++ .../lib/services/trust_referents.dart | 73 ++++++ apps/app_seeds/lib/services/wot_settings.dart | 45 ++++ apps/app_seeds/lib/state/trust_cubit.dart | 101 ++++++-- apps/app_seeds/lib/ui/chat_screen.dart | 50 ++-- apps/app_seeds/lib/ui/profile_screen.dart | 17 ++ .../lib/ui/trust_network_screen.dart | 228 ++++++++++++++++++ apps/app_seeds/pubspec.yaml | 3 + .../test/services/trust_referents_test.dart | 45 ++++ .../test/services/wot_settings_test.dart | 28 +++ .../test/state/trust_cubit_test.dart | 49 ++++ docs/design/open-decisions.md | 2 +- packages/commons_core/lib/commons_core.dart | 1 + .../lib/src/social/nostr_ids.dart | 21 ++ .../lib/src/social/web_of_trust.dart | 50 ++++ .../test/social/nostr_ids_test.dart | 34 +++ .../test/social/web_of_trust_test.dart | 24 ++ 29 files changed, 1111 insertions(+), 39 deletions(-) create mode 100644 apps/app_seeds/assets/trust/referents.json create mode 100644 apps/app_seeds/lib/services/trust_referents.dart create mode 100644 apps/app_seeds/lib/services/wot_settings.dart create mode 100644 apps/app_seeds/lib/ui/trust_network_screen.dart create mode 100644 apps/app_seeds/test/services/trust_referents_test.dart create mode 100644 apps/app_seeds/test/services/wot_settings_test.dart create mode 100644 packages/commons_core/lib/src/social/nostr_ids.dart create mode 100644 packages/commons_core/test/social/nostr_ids_test.dart diff --git a/apps/app_seeds/assets/trust/referents.json b/apps/app_seeds/assets/trust/referents.json new file mode 100644 index 0000000..0de2d07 --- /dev/null +++ b/apps/app_seeds/assets/trust/referents.json @@ -0,0 +1,5 @@ +{ + "_comment": "Web-of-trust bootstrap referents (Duniter 'seeds'): the founding identities every membership calculation trusts by construction. Fill 'referents' with npub (or 64-char hex) public keys of real founding members — e.g. Ğ1 seed-group organisers running Tanemaki. Empty by design until curated; users can also add their own referents in-app. Locale-agnostic.", + "version": 1, + "referents": [] +} diff --git a/apps/app_seeds/lib/app.dart b/apps/app_seeds/lib/app.dart index 56fa473..d21e808 100644 --- a/apps/app_seeds/lib/app.dart +++ b/apps/app_seeds/lib/app.dart @@ -19,6 +19,8 @@ import 'services/profile_store.dart'; import 'services/social_account_store.dart'; import 'services/social_service.dart'; import 'services/social_settings.dart'; +import 'services/trust_referents.dart'; +import 'services/wot_settings.dart'; import 'state/inventory_cubit.dart'; import 'state/variety_detail_cubit.dart'; import 'ui/about_screen.dart'; @@ -34,6 +36,7 @@ import 'ui/offline_banner.dart'; import 'ui/profile_screen.dart'; import 'ui/settings_screen.dart'; import 'ui/theme.dart'; +import 'ui/trust_network_screen.dart'; import 'ui/variety_detail_screen.dart'; /// Root widget. Provides the repositories to the tree and wires go_router: @@ -52,6 +55,8 @@ class TaneApp extends StatelessWidget { this.profileStore, this.profileCache, this.socialAccounts, + this.trustReferents, + this.wotSettings, this.inbox, this.showIntro = false, this.autoBackup, @@ -68,6 +73,8 @@ class TaneApp extends StatelessWidget { profileStore, profileCache, socialAccounts, + trustReferents, + wotSettings, inbox, ); @@ -98,6 +105,10 @@ class TaneApp extends StatelessWidget { /// Optional store of the active social identity, for the profile switcher. final SocialAccountStore? socialAccounts; + /// Web-of-trust bootstrap referents + parameters (network membership). + final TrustReferents? trustReferents; + final WotSettings? wotSettings; + /// App-wide inbox listener; drives the messages list's live refresh. final InboxService? inbox; final bool showIntro; @@ -119,6 +130,8 @@ class TaneApp extends StatelessWidget { ProfileStore? profileStore, ProfileCache? profileCache, SocialAccountStore? socialAccounts, + TrustReferents? trustReferents, + WotSettings? wotSettings, InboxService? inbox, ) { return GoRouter( @@ -175,6 +188,16 @@ class TaneApp extends StatelessWidget { settings: socialSettings, profileStore: profileStore, accounts: socialAccounts, + trustNetworkEnabled: + trustReferents != null && wotSettings != null, + ), + ), + if (trustReferents != null && wotSettings != null) + GoRoute( + path: '/trust', + builder: (context, state) => TrustNetworkScreen( + referents: trustReferents, + wotSettings: wotSettings, ), ), if (social != null && socialSettings != null) @@ -186,6 +209,8 @@ class TaneApp extends StatelessWidget { peerPubkey: state.pathParameters['pubkey']!, messageStore: messageStore, profileCache: profileCache, + trustReferents: trustReferents, + wotSettings: wotSettings, ), ), GoRoute( diff --git a/apps/app_seeds/lib/bootstrap.dart b/apps/app_seeds/lib/bootstrap.dart index e8a2430..85ae2bb 100644 --- a/apps/app_seeds/lib/bootstrap.dart +++ b/apps/app_seeds/lib/bootstrap.dart @@ -20,6 +20,8 @@ import 'services/profile_store.dart'; import 'services/social_account_store.dart'; import 'services/social_service.dart'; import 'services/social_settings.dart'; +import 'services/trust_referents.dart'; +import 'services/wot_settings.dart'; import 'ui/theme.dart'; /// Boots the app WITHOUT blocking the first frame: paints a splash immediately, @@ -69,6 +71,8 @@ class _BootstrapState extends State { profileStore: getIt(), profileCache: getIt(), socialAccounts: getIt(), + trustReferents: getIt(), + wotSettings: getIt(), inbox: inbox, showIntro: !await onboarding.introSeen(), autoBackup: getIt.isRegistered() diff --git a/apps/app_seeds/lib/di/injector.dart b/apps/app_seeds/lib/di/injector.dart index 119d1f7..95bd4ae 100644 --- a/apps/app_seeds/lib/di/injector.dart +++ b/apps/app_seeds/lib/di/injector.dart @@ -38,6 +38,8 @@ import '../services/profile_store.dart'; import '../services/social_account_store.dart'; import '../services/social_service.dart'; import '../services/social_settings.dart'; +import '../services/trust_referents.dart'; +import '../services/wot_settings.dart'; /// The app's service locator. Kept to the composition root — widgets get their /// repositories from here (or via BlocProvider), never by reaching into it deep @@ -146,6 +148,9 @@ Future configureDependencies() async { ..registerSingleton(LocaleStore(secretStore)) ..registerSingleton(SocialSettings(secretStore)) ..registerSingleton(accounts) + // Web of trust: network-wide (not per-identity), so registered once. + ..registerSingleton(TrustReferents(secretStore)) + ..registerSingleton(WotSettings(secretStore)) ..registerSingleton(OfferOutbox(secretStore)) // Per-identity stores are namespaced by the active account's scope. ..registerSingleton( diff --git a/apps/app_seeds/lib/i18n/ast.i18n.json b/apps/app_seeds/lib/i18n/ast.i18n.json index 50acddb..2a1fea4 100644 --- a/apps/app_seeds/lib/i18n/ast.i18n.json +++ b/apps/app_seeds/lib/i18n/ast.i18n.json @@ -443,6 +443,28 @@ "count": "Avalada por {n}", "vouch": "Conozo a esta persona", "vouched": "Avales a esta persona", - "circle": "Nel to círculu" + "circle": "Nel to círculu", + "member": "Miembru de confianza de la rede" + }, + "wot": { + "title": "Rede de confianza", + "open": "Rede de confianza", + "help": "Cómo se decide quién ye miembru de confianza: la xente aválase ente sí, y la confianza estiéndese dende un conxuntu de raíces fundadores.", + "roots": "Raíces de confianza", + "rootsHelp": "Les identidaes fundadores qu'anclen la rede. Amiesta a quien te fíes (apega'l so códigu d'identidá); la pertenencia mídese p'afuera dende elles.", + "noRoots": "Entá nun hai raíces de confianza — nun se pue calcular la pertenencia hasta semar la rede.", + "addRoot": "Amestar una raíz de confianza", + "rootHint": "Apega un códigu d'identidá (npub…)", + "add": "Amestar", + "rootAdded": "Raíz de confianza amestada", + "rootInvalid": "Esi nun ye un códigu d'identidá válidu", + "remove": "Quitar", + "params": "Parámetros avanzaos", + "paramsHelp": "Regles Duniter. Aflóxales pa una rede nueva; apriétales según crez.", + "sigQty": "Avales pa ser miembru", + "stepMax": "Distancia máxima a una raíz", + "validityDays": "Validez del aval (díes)", + "reset": "Reafitar a valores Duniter", + "saved": "Guardáu" } } diff --git a/apps/app_seeds/lib/i18n/en.i18n.json b/apps/app_seeds/lib/i18n/en.i18n.json index e4c9657..500bbfa 100644 --- a/apps/app_seeds/lib/i18n/en.i18n.json +++ b/apps/app_seeds/lib/i18n/en.i18n.json @@ -446,6 +446,28 @@ "count": "Vouched for by {n}", "vouch": "I know this person", "vouched": "You vouch for them", - "circle": "In your circle" + "circle": "In your circle", + "member": "Trusted member of the network" + }, + "wot": { + "title": "Network of trust", + "open": "Network of trust", + "help": "How trusted membership is decided: people vouch for each other, and trust spreads outward from a set of founding roots.", + "roots": "Trust roots", + "rootsHelp": "The founding identities the network is anchored to. Add people you trust (paste their identity code); membership is measured outward from them.", + "noRoots": "No trust roots yet — membership can't be worked out until the network is seeded.", + "addRoot": "Add a trust root", + "rootHint": "Paste an identity code (npub…)", + "add": "Add", + "rootAdded": "Trust root added", + "rootInvalid": "That's not a valid identity code", + "remove": "Remove", + "params": "Advanced parameters", + "paramsHelp": "Duniter rules. Loosen them for a young network; tighten as it grows.", + "sigQty": "Vouches to become a member", + "stepMax": "Max distance from a root", + "validityDays": "Vouch validity (days)", + "reset": "Reset to Duniter defaults", + "saved": "Saved" } } diff --git a/apps/app_seeds/lib/i18n/es.i18n.json b/apps/app_seeds/lib/i18n/es.i18n.json index 8761289..4b8359c 100644 --- a/apps/app_seeds/lib/i18n/es.i18n.json +++ b/apps/app_seeds/lib/i18n/es.i18n.json @@ -445,6 +445,28 @@ "count": "Avalada por {n}", "vouch": "Conozco a esta persona", "vouched": "Avalas a esta persona", - "circle": "En tu círculo" + "circle": "En tu círculo", + "member": "Miembro de confianza de la red" + }, + "wot": { + "title": "Red de confianza", + "open": "Red de confianza", + "help": "Cómo se decide quién es miembro de confianza: la gente se avala entre sí, y la confianza se extiende desde un conjunto de raíces fundadoras.", + "roots": "Raíces de confianza", + "rootsHelp": "Las identidades fundadoras que anclan la red. Añade a quien te fíes (pega su código de identidad); la membresía se mide hacia fuera desde ellas.", + "noRoots": "Aún no hay raíces de confianza — no se puede calcular la membresía hasta sembrar la red.", + "addRoot": "Añadir una raíz de confianza", + "rootHint": "Pega un código de identidad (npub…)", + "add": "Añadir", + "rootAdded": "Raíz de confianza añadida", + "rootInvalid": "Ese no es un código de identidad válido", + "remove": "Quitar", + "params": "Parámetros avanzados", + "paramsHelp": "Reglas Duniter. Aflójalas para una red joven; apriétalas según crece.", + "sigQty": "Avales para ser miembro", + "stepMax": "Distancia máxima a una raíz", + "validityDays": "Validez del aval (días)", + "reset": "Restablecer a valores Duniter", + "saved": "Guardado" } } diff --git a/apps/app_seeds/lib/i18n/pt.i18n.json b/apps/app_seeds/lib/i18n/pt.i18n.json index 0919803..3607d3a 100644 --- a/apps/app_seeds/lib/i18n/pt.i18n.json +++ b/apps/app_seeds/lib/i18n/pt.i18n.json @@ -442,6 +442,28 @@ "count": "Avalizada por {n}", "vouch": "Conheço esta pessoa", "vouched": "Avalizas esta pessoa", - "circle": "No teu círculo" + "circle": "No teu círculo", + "member": "Membro de confiança da rede" + }, + "wot": { + "title": "Rede de confiança", + "open": "Rede de confiança", + "help": "Como se decide quem é membro de confiança: as pessoas avalizam-se entre si, e a confiança estende-se a partir de um conjunto de raízes fundadoras.", + "roots": "Raízes de confiança", + "rootsHelp": "As identidades fundadoras que ancoram a rede. Adiciona quem confias (cola o seu código de identidade); a filiação mede-se para fora a partir delas.", + "noRoots": "Ainda não há raízes de confiança — não se pode calcular a filiação até semear a rede.", + "addRoot": "Adicionar uma raiz de confiança", + "rootHint": "Cola um código de identidade (npub…)", + "add": "Adicionar", + "rootAdded": "Raiz de confiança adicionada", + "rootInvalid": "Esse não é um código de identidade válido", + "remove": "Remover", + "params": "Parâmetros avançados", + "paramsHelp": "Regras Duniter. Afrouxa-as para uma rede jovem; aperta-as à medida que cresce.", + "sigQty": "Avais para ser membro", + "stepMax": "Distância máxima a uma raiz", + "validityDays": "Validade do aval (dias)", + "reset": "Repor para valores Duniter", + "saved": "Guardado" } } diff --git a/apps/app_seeds/lib/i18n/strings.g.dart b/apps/app_seeds/lib/i18n/strings.g.dart index 95fab3a..9fa9072 100644 --- a/apps/app_seeds/lib/i18n/strings.g.dart +++ b/apps/app_seeds/lib/i18n/strings.g.dart @@ -4,9 +4,9 @@ /// To regenerate, run: `dart run slang` /// /// Locales: 4 -/// Strings: 1580 (395 per locale) +/// Strings: 1660 (415 per locale) /// -/// Built on 2026-07-10 at 18:22 UTC +/// Built on 2026-07-10 at 19:01 UTC // coverage:ignore-file // ignore_for_file: type=lint, unused_import diff --git a/apps/app_seeds/lib/i18n/strings_ast.g.dart b/apps/app_seeds/lib/i18n/strings_ast.g.dart index 2becdc8..1e6f78d 100644 --- a/apps/app_seeds/lib/i18n/strings_ast.g.dart +++ b/apps/app_seeds/lib/i18n/strings_ast.g.dart @@ -75,6 +75,7 @@ class TranslationsAst extends Translations with BaseTranslations 'Conozo a esta persona'; @override String get vouched => 'Avales a esta persona'; @override String get circle => 'Nel to círculu'; + @override String get member => 'Miembru de confianza de la rede'; +} + +// Path: wot +class _Translations$wot$ast extends Translations$wot$en { + _Translations$wot$ast._(TranslationsAst root) : this._root = root, super.internal(root); + + final TranslationsAst _root; // ignore: unused_field + + // Translations + @override String get title => 'Rede de confianza'; + @override String get open => 'Rede de confianza'; + @override String get help => 'Cómo se decide quién ye miembru de confianza: la xente aválase ente sí, y la confianza estiéndese dende un conxuntu de raíces fundadores.'; + @override String get roots => 'Raíces de confianza'; + @override String get rootsHelp => 'Les identidaes fundadores qu\'anclen la rede. Amiesta a quien te fíes (apega\'l so códigu d\'identidá); la pertenencia mídese p\'afuera dende elles.'; + @override String get noRoots => 'Entá nun hai raíces de confianza — nun se pue calcular la pertenencia hasta semar la rede.'; + @override String get addRoot => 'Amestar una raíz de confianza'; + @override String get rootHint => 'Apega un códigu d\'identidá (npub…)'; + @override String get add => 'Amestar'; + @override String get rootAdded => 'Raíz de confianza amestada'; + @override String get rootInvalid => 'Esi nun ye un códigu d\'identidá válidu'; + @override String get remove => 'Quitar'; + @override String get params => 'Parámetros avanzaos'; + @override String get paramsHelp => 'Regles Duniter. Aflóxales pa una rede nueva; apriétales según crez.'; + @override String get sigQty => 'Avales pa ser miembru'; + @override String get stepMax => 'Distancia máxima a una raíz'; + @override String get validityDays => 'Validez del aval (díes)'; + @override String get reset => 'Reafitar a valores Duniter'; + @override String get saved => 'Guardáu'; } // Path: intro.slides @@ -1499,6 +1529,26 @@ extension on TranslationsAst { 'trust.vouch' => 'Conozo a esta persona', 'trust.vouched' => 'Avales a esta persona', 'trust.circle' => 'Nel to círculu', + 'trust.member' => 'Miembru de confianza de la rede', + 'wot.title' => 'Rede de confianza', + 'wot.open' => 'Rede de confianza', + 'wot.help' => 'Cómo se decide quién ye miembru de confianza: la xente aválase ente sí, y la confianza estiéndese dende un conxuntu de raíces fundadores.', + 'wot.roots' => 'Raíces de confianza', + 'wot.rootsHelp' => 'Les identidaes fundadores qu\'anclen la rede. Amiesta a quien te fíes (apega\'l so códigu d\'identidá); la pertenencia mídese p\'afuera dende elles.', + 'wot.noRoots' => 'Entá nun hai raíces de confianza — nun se pue calcular la pertenencia hasta semar la rede.', + 'wot.addRoot' => 'Amestar una raíz de confianza', + 'wot.rootHint' => 'Apega un códigu d\'identidá (npub…)', + 'wot.add' => 'Amestar', + 'wot.rootAdded' => 'Raíz de confianza amestada', + 'wot.rootInvalid' => 'Esi nun ye un códigu d\'identidá válidu', + 'wot.remove' => 'Quitar', + 'wot.params' => 'Parámetros avanzaos', + 'wot.paramsHelp' => 'Regles Duniter. Aflóxales pa una rede nueva; apriétales según crez.', + 'wot.sigQty' => 'Avales pa ser miembru', + 'wot.stepMax' => 'Distancia máxima a una raíz', + 'wot.validityDays' => 'Validez del aval (díes)', + 'wot.reset' => 'Reafitar a valores Duniter', + 'wot.saved' => 'Guardáu', _ => null, }; } diff --git a/apps/app_seeds/lib/i18n/strings_en.g.dart b/apps/app_seeds/lib/i18n/strings_en.g.dart index b38bcd2..e454a32 100644 --- a/apps/app_seeds/lib/i18n/strings_en.g.dart +++ b/apps/app_seeds/lib/i18n/strings_en.g.dart @@ -76,6 +76,7 @@ class Translations with BaseTranslations { late final Translations$chatList$en chatList = Translations$chatList$en.internal(_root); late final Translations$chat$en chat = Translations$chat$en.internal(_root); late final Translations$trust$en trust = Translations$trust$en.internal(_root); + late final Translations$wot$en wot = Translations$wot$en.internal(_root); } // Path: app @@ -1423,6 +1424,75 @@ class Translations$trust$en { /// en: 'In your circle' String get circle => 'In your circle'; + + /// en: 'Trusted member of the network' + String get member => 'Trusted member of the network'; +} + +// Path: wot +class Translations$wot$en { + Translations$wot$en.internal(this._root); + + final Translations _root; // ignore: unused_field + + // Translations + + /// en: 'Network of trust' + String get title => 'Network of trust'; + + /// en: 'Network of trust' + String get open => 'Network of trust'; + + /// en: 'How trusted membership is decided: people vouch for each other, and trust spreads outward from a set of founding roots.' + String get help => 'How trusted membership is decided: people vouch for each other, and trust spreads outward from a set of founding roots.'; + + /// en: 'Trust roots' + String get roots => 'Trust roots'; + + /// en: 'The founding identities the network is anchored to. Add people you trust (paste their identity code); membership is measured outward from them.' + String get rootsHelp => 'The founding identities the network is anchored to. Add people you trust (paste their identity code); membership is measured outward from them.'; + + /// en: 'No trust roots yet — membership can't be worked out until the network is seeded.' + String get noRoots => 'No trust roots yet — membership can\'t be worked out until the network is seeded.'; + + /// en: 'Add a trust root' + String get addRoot => 'Add a trust root'; + + /// en: 'Paste an identity code (npub…)' + String get rootHint => 'Paste an identity code (npub…)'; + + /// en: 'Add' + String get add => 'Add'; + + /// en: 'Trust root added' + String get rootAdded => 'Trust root added'; + + /// en: 'That's not a valid identity code' + String get rootInvalid => 'That\'s not a valid identity code'; + + /// en: 'Remove' + String get remove => 'Remove'; + + /// en: 'Advanced parameters' + String get params => 'Advanced parameters'; + + /// en: 'Duniter rules. Loosen them for a young network; tighten as it grows.' + String get paramsHelp => 'Duniter rules. Loosen them for a young network; tighten as it grows.'; + + /// en: 'Vouches to become a member' + String get sigQty => 'Vouches to become a member'; + + /// en: 'Max distance from a root' + String get stepMax => 'Max distance from a root'; + + /// en: 'Vouch validity (days)' + String get validityDays => 'Vouch validity (days)'; + + /// en: 'Reset to Duniter defaults' + String get reset => 'Reset to Duniter defaults'; + + /// en: 'Saved' + String get saved => 'Saved'; } // Path: intro.slides @@ -2279,6 +2349,26 @@ extension on Translations { 'trust.vouch' => 'I know this person', 'trust.vouched' => 'You vouch for them', 'trust.circle' => 'In your circle', + 'trust.member' => 'Trusted member of the network', + 'wot.title' => 'Network of trust', + 'wot.open' => 'Network of trust', + 'wot.help' => 'How trusted membership is decided: people vouch for each other, and trust spreads outward from a set of founding roots.', + 'wot.roots' => 'Trust roots', + 'wot.rootsHelp' => 'The founding identities the network is anchored to. Add people you trust (paste their identity code); membership is measured outward from them.', + 'wot.noRoots' => 'No trust roots yet — membership can\'t be worked out until the network is seeded.', + 'wot.addRoot' => 'Add a trust root', + 'wot.rootHint' => 'Paste an identity code (npub…)', + 'wot.add' => 'Add', + 'wot.rootAdded' => 'Trust root added', + 'wot.rootInvalid' => 'That\'s not a valid identity code', + 'wot.remove' => 'Remove', + 'wot.params' => 'Advanced parameters', + 'wot.paramsHelp' => 'Duniter rules. Loosen them for a young network; tighten as it grows.', + 'wot.sigQty' => 'Vouches to become a member', + 'wot.stepMax' => 'Max distance from a root', + 'wot.validityDays' => 'Vouch validity (days)', + 'wot.reset' => 'Reset to Duniter defaults', + 'wot.saved' => 'Saved', _ => null, }; } diff --git a/apps/app_seeds/lib/i18n/strings_es.g.dart b/apps/app_seeds/lib/i18n/strings_es.g.dart index 4cc4307..c9d0b04 100644 --- a/apps/app_seeds/lib/i18n/strings_es.g.dart +++ b/apps/app_seeds/lib/i18n/strings_es.g.dart @@ -75,6 +75,7 @@ class TranslationsEs extends Translations with BaseTranslations 'Conozco a esta persona'; @override String get vouched => 'Avalas a esta persona'; @override String get circle => 'En tu círculo'; + @override String get member => 'Miembro de confianza de la red'; +} + +// Path: wot +class _Translations$wot$es extends Translations$wot$en { + _Translations$wot$es._(TranslationsEs root) : this._root = root, super.internal(root); + + final TranslationsEs _root; // ignore: unused_field + + // Translations + @override String get title => 'Red de confianza'; + @override String get open => 'Red de confianza'; + @override String get help => 'Cómo se decide quién es miembro de confianza: la gente se avala entre sí, y la confianza se extiende desde un conjunto de raíces fundadoras.'; + @override String get roots => 'Raíces de confianza'; + @override String get rootsHelp => 'Las identidades fundadoras que anclan la red. Añade a quien te fíes (pega su código de identidad); la membresía se mide hacia fuera desde ellas.'; + @override String get noRoots => 'Aún no hay raíces de confianza — no se puede calcular la membresía hasta sembrar la red.'; + @override String get addRoot => 'Añadir una raíz de confianza'; + @override String get rootHint => 'Pega un código de identidad (npub…)'; + @override String get add => 'Añadir'; + @override String get rootAdded => 'Raíz de confianza añadida'; + @override String get rootInvalid => 'Ese no es un código de identidad válido'; + @override String get remove => 'Quitar'; + @override String get params => 'Parámetros avanzados'; + @override String get paramsHelp => 'Reglas Duniter. Aflójalas para una red joven; apriétalas según crece.'; + @override String get sigQty => 'Avales para ser miembro'; + @override String get stepMax => 'Distancia máxima a una raíz'; + @override String get validityDays => 'Validez del aval (días)'; + @override String get reset => 'Restablecer a valores Duniter'; + @override String get saved => 'Guardado'; } // Path: intro.slides @@ -1503,6 +1533,26 @@ extension on TranslationsEs { 'trust.vouch' => 'Conozco a esta persona', 'trust.vouched' => 'Avalas a esta persona', 'trust.circle' => 'En tu círculo', + 'trust.member' => 'Miembro de confianza de la red', + 'wot.title' => 'Red de confianza', + 'wot.open' => 'Red de confianza', + 'wot.help' => 'Cómo se decide quién es miembro de confianza: la gente se avala entre sí, y la confianza se extiende desde un conjunto de raíces fundadoras.', + 'wot.roots' => 'Raíces de confianza', + 'wot.rootsHelp' => 'Las identidades fundadoras que anclan la red. Añade a quien te fíes (pega su código de identidad); la membresía se mide hacia fuera desde ellas.', + 'wot.noRoots' => 'Aún no hay raíces de confianza — no se puede calcular la membresía hasta sembrar la red.', + 'wot.addRoot' => 'Añadir una raíz de confianza', + 'wot.rootHint' => 'Pega un código de identidad (npub…)', + 'wot.add' => 'Añadir', + 'wot.rootAdded' => 'Raíz de confianza añadida', + 'wot.rootInvalid' => 'Ese no es un código de identidad válido', + 'wot.remove' => 'Quitar', + 'wot.params' => 'Parámetros avanzados', + 'wot.paramsHelp' => 'Reglas Duniter. Aflójalas para una red joven; apriétalas según crece.', + 'wot.sigQty' => 'Avales para ser miembro', + 'wot.stepMax' => 'Distancia máxima a una raíz', + 'wot.validityDays' => 'Validez del aval (días)', + 'wot.reset' => 'Restablecer a valores Duniter', + 'wot.saved' => 'Guardado', _ => null, }; } diff --git a/apps/app_seeds/lib/i18n/strings_pt.g.dart b/apps/app_seeds/lib/i18n/strings_pt.g.dart index 6a01663..a3ee3c1 100644 --- a/apps/app_seeds/lib/i18n/strings_pt.g.dart +++ b/apps/app_seeds/lib/i18n/strings_pt.g.dart @@ -75,6 +75,7 @@ class TranslationsPt extends Translations with BaseTranslations 'Conheço esta pessoa'; @override String get vouched => 'Avalizas esta pessoa'; @override String get circle => 'No teu círculo'; + @override String get member => 'Membro de confiança da rede'; +} + +// Path: wot +class _Translations$wot$pt extends Translations$wot$en { + _Translations$wot$pt._(TranslationsPt root) : this._root = root, super.internal(root); + + final TranslationsPt _root; // ignore: unused_field + + // Translations + @override String get title => 'Rede de confiança'; + @override String get open => 'Rede de confiança'; + @override String get help => 'Como se decide quem é membro de confiança: as pessoas avalizam-se entre si, e a confiança estende-se a partir de um conjunto de raízes fundadoras.'; + @override String get roots => 'Raízes de confiança'; + @override String get rootsHelp => 'As identidades fundadoras que ancoram a rede. Adiciona quem confias (cola o seu código de identidade); a filiação mede-se para fora a partir delas.'; + @override String get noRoots => 'Ainda não há raízes de confiança — não se pode calcular a filiação até semear a rede.'; + @override String get addRoot => 'Adicionar uma raiz de confiança'; + @override String get rootHint => 'Cola um código de identidade (npub…)'; + @override String get add => 'Adicionar'; + @override String get rootAdded => 'Raiz de confiança adicionada'; + @override String get rootInvalid => 'Esse não é um código de identidade válido'; + @override String get remove => 'Remover'; + @override String get params => 'Parâmetros avançados'; + @override String get paramsHelp => 'Regras Duniter. Afrouxa-as para uma rede jovem; aperta-as à medida que cresce.'; + @override String get sigQty => 'Avais para ser membro'; + @override String get stepMax => 'Distância máxima a uma raiz'; + @override String get validityDays => 'Validade do aval (dias)'; + @override String get reset => 'Repor para valores Duniter'; + @override String get saved => 'Guardado'; } // Path: intro.slides @@ -1497,6 +1527,26 @@ extension on TranslationsPt { 'trust.vouch' => 'Conheço esta pessoa', 'trust.vouched' => 'Avalizas esta pessoa', 'trust.circle' => 'No teu círculo', + 'trust.member' => 'Membro de confiança da rede', + 'wot.title' => 'Rede de confiança', + 'wot.open' => 'Rede de confiança', + 'wot.help' => 'Como se decide quem é membro de confiança: as pessoas avalizam-se entre si, e a confiança estende-se a partir de um conjunto de raízes fundadoras.', + 'wot.roots' => 'Raízes de confiança', + 'wot.rootsHelp' => 'As identidades fundadoras que ancoram a rede. Adiciona quem confias (cola o seu código de identidade); a filiação mede-se para fora a partir delas.', + 'wot.noRoots' => 'Ainda não há raízes de confiança — não se pode calcular a filiação até semear a rede.', + 'wot.addRoot' => 'Adicionar uma raiz de confiança', + 'wot.rootHint' => 'Cola um código de identidade (npub…)', + 'wot.add' => 'Adicionar', + 'wot.rootAdded' => 'Raiz de confiança adicionada', + 'wot.rootInvalid' => 'Esse não é um código de identidade válido', + 'wot.remove' => 'Remover', + 'wot.params' => 'Parâmetros avançados', + 'wot.paramsHelp' => 'Regras Duniter. Afrouxa-as para uma rede jovem; aperta-as à medida que cresce.', + 'wot.sigQty' => 'Avais para ser membro', + 'wot.stepMax' => 'Distância máxima a uma raiz', + 'wot.validityDays' => 'Validade do aval (dias)', + 'wot.reset' => 'Repor para valores Duniter', + 'wot.saved' => 'Guardado', _ => null, }; } diff --git a/apps/app_seeds/lib/services/trust_referents.dart b/apps/app_seeds/lib/services/trust_referents.dart new file mode 100644 index 0000000..3762e93 --- /dev/null +++ b/apps/app_seeds/lib/services/trust_referents.dart @@ -0,0 +1,73 @@ +import 'dart:convert'; + +import 'package:commons_core/commons_core.dart'; +import 'package:flutter/services.dart' show rootBundle; + +import '../security/secret_store.dart'; + +/// The web-of-trust bootstrap referents (Duniter "seeds"): identities every +/// membership calculation trusts by construction. The network's roots. +/// +/// Sources, unioned: a bundled asset (`assets/trust/referents.json`, a curated +/// founding set — empty until real founders are added) plus referents the user +/// adds in-app (by npub / QR), kept in the keystore. All keys are stored and +/// returned as 64-char hex. Solving cold-start honestly: no invented identities +/// ship, and a user can bootstrap their own roots. +class TrustReferents { + TrustReferents(this._store, {this.assetPath = 'assets/trust/referents.json'}); + + final SecretStore _store; + final String assetPath; + + static const _userKey = 'tane.social.trust.referents'; + + List? _bundledCache; + + /// Every referent (bundled ∪ user-added), as hex public keys. + Future> all() async => + {...await _bundled(), ...await userAdded()}; + + /// Referents the user added themselves (hex), for the management UI. + Future> userAdded() async { + final raw = await _store.read(_userKey); + if (raw == null || raw.isEmpty) return {}; + return raw.split('\n').where((s) => s.isNotEmpty).toSet(); + } + + /// Adds a referent from an [npubOrHex]; returns its hex form. Idempotent. + /// Throws [FormatException] if it is neither an npub nor a hex key. + Future add(String npubOrHex) async { + final hex = npubToHex(npubOrHex); // validates + final current = await userAdded(); + if (current.add(hex)) { + await _store.write(_userKey, current.join('\n')); + } + return hex; + } + + /// Removes a user-added referent (bundled ones can't be removed). + Future remove(String hex) async { + final current = await userAdded()..remove(hex); + await _store.write(_userKey, current.join('\n')); + } + + Future> _bundled() async { + final cached = _bundledCache; + if (cached != null) return cached; + try { + final decoded = jsonDecode(await rootBundle.loadString(assetPath)); + final list = (decoded is Map ? decoded['referents'] : decoded) as List?; + final out = []; + for (final entry in list ?? const []) { + try { + out.add(npubToHex(entry as String)); + } catch (_) { + // Skip a malformed entry rather than break the whole set. + } + } + return _bundledCache = out; + } catch (_) { + return _bundledCache = const []; // no/invalid asset → no bundled referents + } + } +} diff --git a/apps/app_seeds/lib/services/wot_settings.dart b/apps/app_seeds/lib/services/wot_settings.dart new file mode 100644 index 0000000..d2833b5 --- /dev/null +++ b/apps/app_seeds/lib/services/wot_settings.dart @@ -0,0 +1,45 @@ +import 'package:commons_core/commons_core.dart'; + +import '../security/secret_store.dart'; + +/// Persists the web-of-trust parameters (Duniter sigQty / stepMax / validity), +/// so a young network can loosen them and tighten as it grows — exactly how Ğ1 +/// itself evolved. Defaults to [WotParams.duniter]. Keystore-backed. +class WotSettings { + WotSettings(this._store); + + final SecretStore _store; + + static const _sigQtyKey = 'tane.social.wot.sigQty'; + static const _stepMaxKey = 'tane.social.wot.stepMax'; + static const _validityDaysKey = 'tane.social.wot.validityDays'; + + /// The active parameters (any unset field falls back to the Duniter default). + Future params() async { + const base = WotParams.duniter; + final sigQty = await _readInt(_sigQtyKey) ?? base.sigQty; + final stepMax = await _readInt(_stepMaxKey) ?? base.stepMax; + final validityDays = + await _readInt(_validityDaysKey) ?? base.sigValidity.inDays; + return WotParams( + sigQty: sigQty, + stepMax: stepMax, + sigValidity: Duration(days: validityDays), + ); + } + + Future save(WotParams params) async { + await _store.write(_sigQtyKey, '${params.sigQty}'); + await _store.write(_stepMaxKey, '${params.stepMax}'); + await _store.write(_validityDaysKey, '${params.sigValidity.inDays}'); + } + + /// Restores the Ğ1 reference values. + Future reset() => save(WotParams.duniter); + + Future _readInt(String key) async { + final raw = await _store.read(key); + if (raw == null || raw.isEmpty) return null; + return int.tryParse(raw); + } +} diff --git a/apps/app_seeds/lib/state/trust_cubit.dart b/apps/app_seeds/lib/state/trust_cubit.dart index b4670b8..55f96e2 100644 --- a/apps/app_seeds/lib/state/trust_cubit.dart +++ b/apps/app_seeds/lib/state/trust_cubit.dart @@ -5,38 +5,72 @@ import 'package:flutter_bloc/flutter_bloc.dart'; import '../services/social_service.dart'; import '../services/social_settings.dart'; -/// Trust standing of one peer: how many people vouch for them, and whether YOU -/// do. Kept simple on purpose — the full "known member" rule (threshold + -/// distance) and its bootstrap set are a policy decision still open -/// (network-trust.md §2). Vouching + a live count is the useful, honest first -/// step. +/// Where a peer stands, from strongest to weakest signal. +enum TrustTier { + /// Passes the full Duniter membership rule (sigQty certifications from members, + /// within stepMax of a bootstrap referent). + networkMember, + + /// In your personal circle (you vouch, or a friend-of-a-friend does). + inYourCircle, + + /// Vouched for by someone, but not (yet) a member nor in your circle. + vouched, + + /// No certifications seen. + unknown, +} + +/// Trust standing of one peer: the full Duniter membership verdict, your own +/// circle, and the raw certifier count — computed from the public certification +/// graph with the active [WotParams] and bootstrap referents. class TrustState extends Equatable { const TrustState({ this.certifierCount = 0, this.iVouch = false, this.knownToYou = false, + this.isNetworkMember = false, + this.networkBootstrapped = false, this.loading = true, this.busy = false, }); - /// How many people (network-wide) vouch for the peer. + /// How many people (network-wide) currently vouch for the peer. final int certifierCount; /// Whether this user vouches for the peer. final bool iVouch; - /// Whether the peer is within the user's own circle of trust — you vouch for - /// them, or someone you vouch for does (friend-of-a-friend). This is the - /// meaningful signal; a raw network count invites spam. + /// Whether the peer is within the user's own circle (you, or a + /// friend-of-a-friend, vouch). Spam-resistant and works from day one. final bool knownToYou; + /// Whether the peer is a full member per the Duniter rule (sigQty + stepMax + /// from the bootstrap referents). + final bool isNetworkMember; + + /// Whether any bootstrap referents exist at all. When false, network + /// membership can't be determined yet (the trust net isn't seeded) and the UI + /// should say so instead of implying the peer failed the rule. + final bool networkBootstrapped; + final bool loading; final bool busy; + /// The strongest applicable signal, for the badge. + TrustTier get tier { + if (isNetworkMember) return TrustTier.networkMember; + if (knownToYou) return TrustTier.inYourCircle; + if (certifierCount > 0) return TrustTier.vouched; + return TrustTier.unknown; + } + TrustState copyWith({ int? certifierCount, bool? iVouch, bool? knownToYou, + bool? isNetworkMember, + bool? networkBootstrapped, bool? loading, bool? busy, }) => @@ -44,21 +78,34 @@ class TrustState extends Equatable { certifierCount: certifierCount ?? this.certifierCount, iVouch: iVouch ?? this.iVouch, knownToYou: knownToYou ?? this.knownToYou, + isNetworkMember: isNetworkMember ?? this.isNetworkMember, + networkBootstrapped: networkBootstrapped ?? this.networkBootstrapped, loading: loading ?? this.loading, busy: busy ?? this.busy, ); @override - List get props => - [certifierCount, iVouch, knownToYou, loading, busy]; + List get props => [ + certifierCount, + iVouch, + knownToYou, + isNetworkMember, + networkBootstrapped, + loading, + busy, + ]; } -/// Reads and toggles this user's vouch for [peerPubkey] over a [TrustTransport]. +/// Reads and toggles this user's vouch for [peerPubkey] over a [TrustTransport], +/// and computes the full Duniter membership verdict against the bootstrap +/// [referents] and active [params]. class TrustCubit extends Cubit { TrustCubit( this._transport, { required this.peerPubkey, required this.selfPubkey, + this.referents = const {}, + this.params = WotParams.duniter, Future Function()? onDispose, }) : _onDispose = onDispose, super(const TrustState()); @@ -66,17 +113,26 @@ class TrustCubit extends Cubit { final TrustTransport? _transport; final String peerPubkey; final String selfPubkey; + + /// Bootstrap referents (Duniter seeds) the membership rule trusts by + /// construction. Empty = the network isn't seeded yet. + final Set referents; + + /// Active web-of-trust parameters (sigQty / stepMax / validity). + final WotParams params; final Future Function()? _onDispose; bool get isOnline => _transport != null; - /// Bootstrap "circle" rule: one vouch from your side, out to a friend-of-a- - /// friend. Deliberately loose (the full member policy — threshold/distance — - /// is still open, network-trust.md §2); this is just "people near you". + /// Personal "circle" rule: one vouch from your side, out to a friend-of-a- + /// friend. Loose by design — "people near you", separate from formal + /// membership. static const _circleThreshold = 1; static const _circleDistance = 2; - /// Loads the peer's certifiers and whether they're within your circle. + /// Loads the certification graph and computes: the peer's certifier count, + /// whether you vouch, whether they're in your circle, and whether they pass + /// the full Duniter membership rule against the bootstrap referents. Future load() async { final transport = _transport; if (transport == null) { @@ -94,15 +150,23 @@ class TrustCubit extends Cubit { threshold: _circleThreshold, maxDistance: _circleDistance, ); + // Full membership: only meaningful once the trust net has referents. + final members = referents.isEmpty + ? const {} + : wot.membersWith(seeds: referents, params: params); emit(state.copyWith( certifierCount: certifiers.length, iVouch: certifiers.contains(selfPubkey), knownToYou: circle.contains(peerPubkey), + isNetworkMember: members.contains(peerPubkey), + networkBootstrapped: referents.isNotEmpty, loading: false, )); } /// Adds or removes this user's vouch, then reloads. Never vouches for self. + /// The certification is issued with the active validity so it expires and + /// must be renewed (Duniter rule). Future toggleVouch() async { final transport = _transport; if (transport == null || peerPubkey == selfPubkey) return; @@ -110,7 +174,10 @@ class TrustCubit extends Cubit { if (state.iVouch) { await transport.revoke(subjectPubkey: peerPubkey); } else { - await transport.certify(subjectPubkey: peerPubkey); + await transport.certify( + subjectPubkey: peerPubkey, + validity: params.sigValidity, + ); } await load(); emit(state.copyWith(busy: false)); diff --git a/apps/app_seeds/lib/ui/chat_screen.dart b/apps/app_seeds/lib/ui/chat_screen.dart index 97a555c..5f16bba 100644 --- a/apps/app_seeds/lib/ui/chat_screen.dart +++ b/apps/app_seeds/lib/ui/chat_screen.dart @@ -1,5 +1,6 @@ import 'dart:async'; +import 'package:commons_core/commons_core.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; @@ -10,6 +11,8 @@ import '../services/message_store.dart'; import '../services/profile_cache.dart'; import '../services/social_service.dart'; import '../services/social_settings.dart'; +import '../services/trust_referents.dart'; +import '../services/wot_settings.dart'; import '../state/messages_cubit.dart'; import '../state/trust_cubit.dart'; import 'theme.dart'; @@ -24,6 +27,8 @@ class ChatScreen extends StatefulWidget { required this.peerPubkey, this.messageStore, this.profileCache, + this.trustReferents, + this.wotSettings, super.key, }); @@ -37,6 +42,10 @@ class ChatScreen extends StatefulWidget { /// Optional cache of peer display names; null in tests. final ProfileCache? profileCache; + /// Web-of-trust bootstrap referents + parameters, for the membership verdict. + final TrustReferents? trustReferents; + final WotSettings? wotSettings; + @override State createState() => _ChatScreenState(); } @@ -69,6 +78,9 @@ class _ChatScreenState extends State { } } final cachedName = await widget.profileCache?.name(widget.peerPubkey); + final referents = await widget.trustReferents?.all() ?? const {}; + final wotParams = + await widget.wotSettings?.params() ?? WotParams.duniter; if (!mounted) { await session?.close(); return; @@ -80,8 +92,13 @@ class _ChatScreenState extends State { selfPubkey: self, store: widget.messageStore, )..start(); - final trust = TrustCubit(session?.trust, - peerPubkey: widget.peerPubkey, selfPubkey: self); + final trust = TrustCubit( + session?.trust, + peerPubkey: widget.peerPubkey, + selfPubkey: self, + referents: referents, + params: wotParams, + ); unawaited(trust.load()); setState(() { _session = session; @@ -249,30 +266,33 @@ class _TrustBanner extends StatelessWidget { builder: (context, state) { final cubit = context.read(); if (!cubit.isOnline || state.loading) return const SizedBox.shrink(); - final known = state.knownToYou; + // Strongest applicable signal decides the badge (member > circle > + // vouched > unknown). + final (IconData icon, String label, bool strong) = switch (state.tier) { + TrustTier.networkMember => (Icons.verified, t.trust.member, true), + TrustTier.inYourCircle => (Icons.verified_user, t.trust.circle, true), + TrustTier.vouched => ( + Icons.people_outline, + t.trust.count(n: state.certifierCount), + false, + ), + TrustTier.unknown => (Icons.person_outline, t.trust.none, false), + }; return Container( width: double.infinity, color: seedPrimaryContainer.withValues(alpha: 0.5), padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), child: Row( children: [ - Icon( - known ? Icons.verified : Icons.verified_user_outlined, - size: 18, - color: seedGreen, - ), + Icon(icon, size: 18, color: strong ? seedGreen : seedMuted), const SizedBox(width: 8), Expanded( child: Text( - known - ? t.trust.circle - : state.certifierCount == 0 - ? t.trust.none - : t.trust.count(n: state.certifierCount), + label, style: TextStyle( - color: known ? seedGreen : seedOnSurface, + color: strong ? seedGreen : seedOnSurface, fontSize: 13, - fontWeight: known ? FontWeight.w600 : FontWeight.w400, + fontWeight: strong ? FontWeight.w600 : FontWeight.w400, ), ), ), diff --git a/apps/app_seeds/lib/ui/profile_screen.dart b/apps/app_seeds/lib/ui/profile_screen.dart index 552a5cb..303a9ee 100644 --- a/apps/app_seeds/lib/ui/profile_screen.dart +++ b/apps/app_seeds/lib/ui/profile_screen.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; +import 'package:go_router/go_router.dart'; import '../di/injector.dart' show switchSocialAccount; import '../i18n/strings.g.dart'; @@ -21,6 +22,7 @@ class ProfileScreen extends StatefulWidget { required this.settings, required this.profileStore, required this.accounts, + this.trustNetworkEnabled = false, super.key, }); @@ -29,6 +31,9 @@ class ProfileScreen extends StatefulWidget { final ProfileStore profileStore; final SocialAccountStore accounts; + /// Whether to offer the "Network of trust" entry (needs the WoT services). + final bool trustNetworkEnabled; + @override State createState() => _ProfileScreenState(); } @@ -207,6 +212,18 @@ class _ProfileScreenState extends State { onSwitch: (a) => _switchTo(a), onNew: _newIdentity, ), + if (widget.trustNetworkEnabled) ...[ + const SizedBox(height: 12), + const Divider(), + ListTile( + key: const Key('profile.trustNetwork'), + contentPadding: EdgeInsets.zero, + leading: const Icon(Icons.hub_outlined, color: seedGreen), + title: Text(t.wot.open), + trailing: const Icon(Icons.chevron_right, color: seedMuted), + onTap: () => context.push('/trust'), + ), + ], ], ), ); diff --git a/apps/app_seeds/lib/ui/trust_network_screen.dart b/apps/app_seeds/lib/ui/trust_network_screen.dart new file mode 100644 index 0000000..a0d5e8b --- /dev/null +++ b/apps/app_seeds/lib/ui/trust_network_screen.dart @@ -0,0 +1,228 @@ +import 'package:commons_core/commons_core.dart'; +import 'package:flutter/material.dart'; + +import '../i18n/strings.g.dart'; +import '../services/profile_cache.dart' show shortPubkey; +import '../services/trust_referents.dart'; +import '../services/wot_settings.dart'; +import 'theme.dart'; + +/// Manage the web of trust: the bootstrap "roots" (Duniter seeds) membership is +/// measured from, and the advanced parameters (sigQty / stepMax / validity). +/// This is where a young network is seeded and tuned — progressive disclosure, +/// reached from the profile. +class TrustNetworkScreen extends StatefulWidget { + const TrustNetworkScreen({ + required this.referents, + required this.wotSettings, + super.key, + }); + + final TrustReferents referents; + final WotSettings wotSettings; + + @override + State createState() => _TrustNetworkScreenState(); +} + +class _TrustNetworkScreenState extends State { + final _input = TextEditingController(); + Set _roots = {}; + WotParams _params = WotParams.duniter; + bool _loading = true; + String? _error; + + @override + void initState() { + super.initState(); + _load(); + } + + Future _load() async { + final roots = await widget.referents.userAdded(); + final params = await widget.wotSettings.params(); + if (!mounted) return; + setState(() { + _roots = roots; + _params = params; + _loading = false; + }); + } + + Future _addRoot() async { + final t = context.t; + final messenger = ScaffoldMessenger.of(context); + try { + await widget.referents.add(_input.text); + _input.clear(); + await _load(); + messenger.showSnackBar(SnackBar(content: Text(t.wot.rootAdded))); + } on FormatException { + setState(() => _error = t.wot.rootInvalid); + } + } + + Future _removeRoot(String hex) async { + await widget.referents.remove(hex); + await _load(); + } + + Future _saveParams(WotParams next) async { + await widget.wotSettings.save(next); + if (!mounted) return; + setState(() => _params = next); + ScaffoldMessenger.of(context) + .showSnackBar(SnackBar(content: Text(context.t.wot.saved))); + } + + @override + void dispose() { + _input.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final t = context.t; + return Scaffold( + appBar: AppBar(title: Text(t.wot.title)), + body: _loading + ? const Center(child: CircularProgressIndicator()) + : ListView( + padding: const EdgeInsets.all(20), + children: [ + Text(t.wot.help, + style: const TextStyle(color: seedMuted, fontSize: 13)), + const SizedBox(height: 20), + Text(t.wot.roots, + style: const TextStyle( + fontWeight: FontWeight.w600, color: seedOnSurface)), + const SizedBox(height: 4), + Text(t.wot.rootsHelp, + style: const TextStyle(color: seedMuted, fontSize: 12)), + const SizedBox(height: 8), + if (_roots.isEmpty) + Padding( + padding: const EdgeInsets.symmetric(vertical: 8), + child: Text(t.wot.noRoots, + style: + const TextStyle(color: seedMuted, fontSize: 13)), + ), + for (final hex in _roots) + ListTile( + contentPadding: EdgeInsets.zero, + leading: const Icon(Icons.hub_outlined, color: seedGreen), + title: Text(shortPubkey(hex), + style: const TextStyle( + fontFamily: 'monospace', fontSize: 13)), + trailing: IconButton( + icon: const Icon(Icons.close, size: 20), + tooltip: t.wot.remove, + onPressed: () => _removeRoot(hex), + ), + ), + const SizedBox(height: 8), + TextField( + key: const Key('wot.rootInput'), + controller: _input, + onChanged: (_) { + if (_error != null) setState(() => _error = null); + }, + decoration: InputDecoration( + labelText: t.wot.addRoot, + hintText: t.wot.rootHint, + errorText: _error, + suffixIcon: IconButton( + key: const Key('wot.addRoot'), + icon: const Icon(Icons.add), + onPressed: _addRoot, + ), + ), + ), + const SizedBox(height: 12), + const Divider(), + ExpansionTile( + tilePadding: EdgeInsets.zero, + title: Text(t.wot.params), + subtitle: Text(t.wot.paramsHelp, + style: const TextStyle(fontSize: 12)), + children: [ + _Stepper( + label: t.wot.sigQty, + value: _params.sigQty, + min: 1, + onChanged: (v) => + _saveParams(_params.copyWith(sigQty: v)), + ), + _Stepper( + label: t.wot.stepMax, + value: _params.stepMax, + min: 1, + onChanged: (v) => + _saveParams(_params.copyWith(stepMax: v)), + ), + _Stepper( + label: t.wot.validityDays, + value: _params.sigValidity.inDays, + min: 1, + step: 30, + onChanged: (v) => _saveParams( + _params.copyWith(sigValidity: Duration(days: v))), + ), + const SizedBox(height: 8), + Align( + alignment: AlignmentDirectional.centerStart, + child: TextButton( + onPressed: () => _saveParams(WotParams.duniter), + child: Text(t.wot.reset), + ), + ), + ], + ), + ], + ), + ); + } +} + +/// A "- value +" row for an integer parameter, clamped to [min]. +class _Stepper extends StatelessWidget { + const _Stepper({ + required this.label, + required this.value, + required this.onChanged, + this.min = 0, + this.step = 1, + }); + + final String label; + final int value; + final int min; + final int step; + final ValueChanged onChanged; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 4), + child: Row( + children: [ + Expanded(child: Text(label)), + IconButton( + icon: const Icon(Icons.remove_circle_outline), + onPressed: + value - step >= min ? () => onChanged(value - step) : null, + ), + SizedBox( + width: 44, + child: Text('$value', textAlign: TextAlign.center), + ), + IconButton( + icon: const Icon(Icons.add_circle_outline), + onPressed: () => onChanged(value + step), + ), + ], + ), + ); + } +} diff --git a/apps/app_seeds/pubspec.yaml b/apps/app_seeds/pubspec.yaml index f6a3f49..b9317ed 100644 --- a/apps/app_seeds/pubspec.yaml +++ b/apps/app_seeds/pubspec.yaml @@ -124,6 +124,9 @@ flutter: # Seed strings for slang live under lib/i18n (not a Flutter asset; compiled). assets: - assets/catalog/species.json + # Web-of-trust bootstrap referents (Duniter "seeds"). Empty until real + # founding identities are curated; users bootstrap by adding their own. + - assets/trust/referents.json - assets/logo.png # Bundled so the Linux runner can use it as the GTK window icon. - assets/icon.png diff --git a/apps/app_seeds/test/services/trust_referents_test.dart b/apps/app_seeds/test/services/trust_referents_test.dart new file mode 100644 index 0000000..a28cbed --- /dev/null +++ b/apps/app_seeds/test/services/trust_referents_test.dart @@ -0,0 +1,45 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:tane/services/trust_referents.dart'; + +import '../support/test_support.dart'; + +void main() { + const npub = + 'npub180cvv07tjdrrgpa0j7j7tmnyl2yr6yr7l8j4s3evf6u64th6gkwsyjh6w6'; + const hex = + '3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d'; + + // No bundled asset in the unit test bundle → _bundled() degrades to empty, so + // these exercise the user-added set (the cold-start bootstrap path). + late TrustReferents referents; + setUp(() => referents = TrustReferents(InMemorySecretStore())); + + test('starts empty', () async { + expect(await referents.all(), isEmpty); + expect(await referents.userAdded(), isEmpty); + }); + + test('adds a referent from an npub, storing hex', () async { + final stored = await referents.add(npub); + expect(stored, hex); + expect(await referents.userAdded(), {hex}); + expect(await referents.all(), contains(hex)); + }); + + test('accepts a hex key directly and is idempotent', () async { + await referents.add(hex); + await referents.add(npub); // same identity, npub form + expect(await referents.userAdded(), {hex}); + }); + + test('rejects an invalid identity code', () async { + expect(() => referents.add('not-a-key'), throwsFormatException); + expect(await referents.userAdded(), isEmpty); + }); + + test('removes a referent', () async { + await referents.add(hex); + await referents.remove(hex); + expect(await referents.userAdded(), isEmpty); + }); +} diff --git a/apps/app_seeds/test/services/wot_settings_test.dart b/apps/app_seeds/test/services/wot_settings_test.dart new file mode 100644 index 0000000..dbcc8fb --- /dev/null +++ b/apps/app_seeds/test/services/wot_settings_test.dart @@ -0,0 +1,28 @@ +import 'package:commons_core/commons_core.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:tane/services/wot_settings.dart'; + +import '../support/test_support.dart'; + +void main() { + late WotSettings settings; + setUp(() => settings = WotSettings(InMemorySecretStore())); + + test('defaults to the Duniter parameters', () async { + expect(await settings.params(), WotParams.duniter); + }); + + test('persists saved parameters', () async { + const custom = WotParams( + sigQty: 3, stepMax: 4, sigValidity: Duration(days: 180)); + await settings.save(custom); + expect(await settings.params(), custom); + }); + + test('reset restores the Duniter defaults', () async { + await settings.save(const WotParams( + sigQty: 1, stepMax: 1, sigValidity: Duration(days: 30))); + await settings.reset(); + expect(await settings.params(), WotParams.duniter); + }); +} diff --git a/apps/app_seeds/test/state/trust_cubit_test.dart b/apps/app_seeds/test/state/trust_cubit_test.dart index 51d0672..d11e746 100644 --- a/apps/app_seeds/test/state/trust_cubit_test.dart +++ b/apps/app_seeds/test/state/trust_cubit_test.dart @@ -91,6 +91,55 @@ void main() { await outside.close(); }); + test('network membership: enough referent vouches → member', () async { + const params = + WotParams(sigQty: 2, stepMax: 5, sigValidity: Duration(days: 365)); + final transport = FakeTrustTransport(me) + ..addCert('r1', peer) + ..addCert('r2', peer); + final cubit = TrustCubit( + transport, + peerPubkey: peer, + selfPubkey: me, + referents: {'r1', 'r2'}, + params: params, + ); + await cubit.load(); + expect(cubit.state.networkBootstrapped, isTrue); + expect(cubit.state.isNetworkMember, isTrue); + expect(cubit.state.tier, TrustTier.networkMember); + await cubit.close(); + }); + + test('with no referents, membership is undetermined (not a member)', + () async { + final transport = FakeTrustTransport(me) + ..addCert('r1', peer) + ..addCert('r2', peer); + // referents default to {} → the trust net isn't seeded. + final cubit = TrustCubit(transport, peerPubkey: peer, selfPubkey: me); + await cubit.load(); + expect(cubit.state.networkBootstrapped, isFalse); + expect(cubit.state.isNetworkMember, isFalse); + await cubit.close(); + }); + + test('tier falls back to vouched, then unknown', () async { + final vouched = TrustCubit( + FakeTrustTransport(me)..addCert('someone', peer), + peerPubkey: peer, + selfPubkey: me); + await vouched.load(); + expect(vouched.state.tier, TrustTier.vouched); + await vouched.close(); + + final unknown = TrustCubit(FakeTrustTransport(me), + peerPubkey: 'nobody', selfPubkey: me); + await unknown.load(); + expect(unknown.state.tier, TrustTier.unknown); + await unknown.close(); + }); + test('never vouches for self', () async { final cubit = TrustCubit(FakeTrustTransport(me), peerPubkey: me, selfPubkey: me); diff --git a/docs/design/open-decisions.md b/docs/design/open-decisions.md index 162d07c..1f372be 100644 --- a/docs/design/open-decisions.md +++ b/docs/design/open-decisions.md @@ -21,7 +21,7 @@ Estas fijan `schemaVersion = 1` y el arranque técnico: - **Transporte de la capa social → Nostr (confirmado).** No se renuncia a Nostr: **Duniter no tiene mensajería**, así que Nostr es la vía para mensajes y ofertas. Se mantiene "una sola identidad" derivando la clave **secp256k1** (Nostr) de la semilla raíz Ğ1. Falta: confirmar madurez de los NIPs y la ruta de derivación. (Datapods de Duniter descartados: no están en servicio.) → [g1-integration.md](g1-integration.md) - **Estrategia de relays:** app-as-relay oportunista + proximidad física + relays comunitarios. → network-trust §3 -- **Parámetros de la red de confianza:** umbral de certificaciones (¿5, estilo Duniter?), distancia, caducidad, certificación por colectivo. → network-trust §2 +- **Parámetros de la red de confianza — RESUELTO/IMPLEMENTADO (2026-07-10).** Modelo **membresía Duniter completa**: regla pura `WebOfTrust.membersWith(seeds, WotParams)` con `WotParams` (sigQty/stepMax/sigValidity) por defecto Ğ1 (5/5/1año) **configurables** (`WotSettings`, keystore) — una red joven los afloja y aprieta al crecer. Cold-start honesto (sin inventar identidades): referentes "semilla" desde un asset empaquetado (vacío hasta curar fundadores reales) ∪ referentes que el usuario añade por npub/QR (`TrustReferents`). Caducidad ya la ponía el transporte (`certify` con `expiration`). UI: badge por `TrustTier` (miembro de la red > en tu círculo > avalado > desconocido) en el chat, y pantalla "Red de confianza" (gestión de raíces + parámetros) desde el perfil. Certificación por colectivo queda para sharing-model §6. → network-trust §2 - **Mensajería:** alcance v1 (1:1 atada a oferta), apoyo en NIP-17. → network-trust §4 - **Reputación** atada a un trato/oferta cerrada (evitar reseñas falsas). → sharing-model §6 - **Precio:** ¿monedas comunitarias / de tiempo además de dinero? → sharing-model §6 diff --git a/packages/commons_core/lib/commons_core.dart b/packages/commons_core/lib/commons_core.dart index 515a513..451eb1c 100644 --- a/packages/commons_core/lib/commons_core.dart +++ b/packages/commons_core/lib/commons_core.dart @@ -14,6 +14,7 @@ export 'src/ids/id_gen.dart'; export 'src/social/certification.dart'; export 'src/social/geohash.dart'; export 'src/social/message_transport.dart'; +export 'src/social/nostr_ids.dart'; export 'src/social/nostr/nostr_channel.dart'; export 'src/social/nostr/nostr_connection.dart'; export 'src/social/nostr/nostr_message_transport.dart'; diff --git a/packages/commons_core/lib/src/social/nostr_ids.dart b/packages/commons_core/lib/src/social/nostr_ids.dart new file mode 100644 index 0000000..556964d --- /dev/null +++ b/packages/commons_core/lib/src/social/nostr_ids.dart @@ -0,0 +1,21 @@ +import 'package:nostr/nostr.dart'; + +/// Decodes a NIP-19 `npub…` into its 64-char hex public key. +/// +/// Accepts an already-hex key unchanged (so callers can take either form), and +/// throws [FormatException] on anything that is neither. Used to add web-of-trust +/// referents shared as an `npub` (or scanned from a QR). +String npubToHex(String input) { + final value = input.trim(); + if (_isHex64(value)) return value.toLowerCase(); + if (!value.startsWith('npub1')) { + throw const FormatException('not an npub or hex public key'); + } + final decoded = Nip19.decode(payload: value); + if (decoded.prefix != Nip19Prefix.npub || !_isHex64(decoded.data)) { + throw const FormatException('not an npub public key'); + } + return decoded.data.toLowerCase(); +} + +bool _isHex64(String s) => RegExp(r'^[0-9a-fA-F]{64}$').hasMatch(s); diff --git a/packages/commons_core/lib/src/social/web_of_trust.dart b/packages/commons_core/lib/src/social/web_of_trust.dart index 70a9eeb..e58f113 100644 --- a/packages/commons_core/lib/src/social/web_of_trust.dart +++ b/packages/commons_core/lib/src/social/web_of_trust.dart @@ -1,5 +1,44 @@ +import 'package:equatable/equatable.dart'; + import 'certification.dart'; +/// The Duniter/Ğ1 web-of-trust parameters. Kept as data (not magic numbers) so +/// they can be persisted and tuned as the network grows — a young network needs +/// looser values than a mature one, exactly as Ğ1 itself started. +/// +/// - [sigQty]: certifications from existing members needed to become a member. +/// - [stepMax]: max certification hops from a bootstrap referent (seed). +/// - [sigValidity]: how long a certification counts before it must be renewed. +class WotParams extends Equatable { + const WotParams({ + required this.sigQty, + required this.stepMax, + required this.sigValidity, + }); + + /// The Ğ1 reference values (sigQty 5, stepMax 5, ~1-year validity — Ğ1 uses a + /// longer window, but the seed app renews yearly to match `certify`'s default). + static const duniter = WotParams( + sigQty: 5, + stepMax: 5, + sigValidity: Duration(days: 365), + ); + + final int sigQty; + final int stepMax; + final Duration sigValidity; + + WotParams copyWith({int? sigQty, int? stepMax, Duration? sigValidity}) => + WotParams( + sigQty: sigQty ?? this.sigQty, + stepMax: stepMax ?? this.stepMax, + sigValidity: sigValidity ?? this.sigValidity, + ); + + @override + List get props => [sigQty, stepMax, sigValidity]; +} + /// Pure web-of-trust computation (Duniter/Ğ1 rules), Flutter-free and I/O-free. /// Answers "who counts as a known member" from certification edges, using two /// rules: **N certifications from existing members** (sigQty) and **within @@ -76,6 +115,17 @@ class WebOfTrust { members(seeds: seeds, threshold: threshold, maxDistance: maxDistance) .contains(pubkey); + /// Members using a [WotParams] bundle (Duniter naming: sigQty/stepMax). + Set membersWith({ + required Set seeds, + required WotParams params, + }) => + members( + seeds: seeds, + threshold: params.sigQty, + maxDistance: params.stepMax, + ); + /// BFS hop-distance from the nearest seed over certification edges. Map _distancesFromSeeds(Set seeds) { final dist = {for (final s in seeds) s: 0}; diff --git a/packages/commons_core/test/social/nostr_ids_test.dart b/packages/commons_core/test/social/nostr_ids_test.dart new file mode 100644 index 0000000..a3ee3bd --- /dev/null +++ b/packages/commons_core/test/social/nostr_ids_test.dart @@ -0,0 +1,34 @@ +import 'package:commons_core/commons_core.dart'; +import 'package:test/test.dart'; + +void main() { + group('npubToHex', () { + // The canonical NIP-19 example pair. + const npub = + 'npub180cvv07tjdrrgpa0j7j7tmnyl2yr6yr7l8j4s3evf6u64th6gkwsyjh6w6'; + const hex = + '3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d'; + + test('decodes a bech32 npub to its hex public key', () { + expect(npubToHex(npub), hex); + }); + + test('passes a 64-char hex key through (lower-cased)', () { + expect(npubToHex(hex), hex); + expect(npubToHex(hex.toUpperCase()), hex); + }); + + test('trims surrounding whitespace', () { + expect(npubToHex(' $npub '), hex); + }); + + test('rejects an nsec (secret key), not a public identity', () { + expect(() => npubToHex('nsec1' 'garbage'), throwsFormatException); + }); + + test('rejects nonsense', () { + expect(() => npubToHex('not-a-key'), throwsFormatException); + expect(() => npubToHex(''), throwsFormatException); + }); + }); +} diff --git a/packages/commons_core/test/social/web_of_trust_test.dart b/packages/commons_core/test/social/web_of_trust_test.dart index 066067b..6328144 100644 --- a/packages/commons_core/test/social/web_of_trust_test.dart +++ b/packages/commons_core/test/social/web_of_trust_test.dart @@ -79,4 +79,28 @@ void main() { final wot = WebOfTrust.fromCertifications([cert('a', 'a')], now: now); expect(wot.certifierCount('a'), 0); }); + + group('WotParams', () { + test('Duniter defaults are sigQty 5, stepMax 5, 1-year validity', () { + const p = WotParams.duniter; + expect(p.sigQty, 5); + expect(p.stepMax, 5); + expect(p.sigValidity, const Duration(days: 365)); + }); + + test('membersWith uses sigQty/stepMax like the raw rule', () { + final seeds = {'s1', 's2', 's3'}; + final certs = [for (final s in seeds) cert(s, 'newcomer')]; + final wot = WebOfTrust.fromCertifications(certs, now: now); + const params = WotParams( + sigQty: 3, stepMax: 5, sigValidity: Duration(days: 365)); + expect(wot.membersWith(seeds: seeds, params: params), + contains('newcomer')); + // One short of sigQty → not a member. + const strict = WotParams( + sigQty: 4, stepMax: 5, sigValidity: Duration(days: 365)); + expect(wot.membersWith(seeds: seeds, params: strict), + isNot(contains('newcomer'))); + }); + }); }