From 2f076ccf29731b7c57c21efab1f202f79231e030 Mon Sep 17 00:00:00 2001 From: flemming-it Date: Tue, 21 Jul 2026 13:14:13 +0200 Subject: [PATCH] feat(store,doctor): surface the hub's trust + exposure data (0.77.0) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The trust-gate dialog replaces its generic 'no per-entry status yet (alpha)' note with the hub's classified verification statement — pinned store key / trusted publishers (green), installs without signature checking (amber), install would be refused (red), bridge entry (neutral) — via one shared describeInstallVerification mapping. Against a pre-0.23 hub the field is empty and the old honest wording stays (pinned by test). Information architecture: policy-off is a GLOBAL fact, so it appears as ONE ChainInlineHelp notice above the store grid instead of a warning pill on every card (card noise); only 'blocked' — a genuine per-source anomaly — earns a card pill. Doctor's host services show the hub-classified network reach per endpoint (local only / private network / publicly reachable with a protect-it hint / reach unknown). Verified end-to-end against the live dev hub (guide harness): the wire field arrives, the store page shows exactly one policy notice and quiet cards; trust-gate variants captured via the dialog harness. Suite 123 green. Signed-off-by: flemming-it --- CHANGELOG.md | 22 +++++ integration_test/dialog_shots_test.dart | 61 +++++++++++++ lib/data/about_info.dart | 2 +- lib/data/hub.dart | 24 ++++- lib/data/install_verification.dart | 65 +++++++++++++ lib/l10n/app_de.arb | 19 ++++ lib/l10n/app_en.arb | 19 ++++ lib/l10n/app_localizations.dart | 114 +++++++++++++++++++++++ lib/l10n/app_localizations_de.dart | 68 ++++++++++++++ lib/l10n/app_localizations_en.dart | 66 ++++++++++++++ lib/pages/doctor.dart | 40 ++++++++ lib/pages/store.dart | 52 +++++++++++ lib/widgets/chain_install_confirm.dart | 75 +++++++++++++-- pubspec.yaml | 2 +- test/install_verification_test.dart | 116 ++++++++++++++++++++++++ 15 files changed, 732 insertions(+), 13 deletions(-) create mode 100644 lib/data/install_verification.dart create mode 100644 test/install_verification_test.dart diff --git a/CHANGELOG.md b/CHANGELOG.md index 9c9a7b5..82f32ff 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,28 @@ lockstep. ## Unreleased +### Added — real trust + exposure data (0.77.0) + +- **Per-entry install verification.** The hub now reports (since + 0.23) how an install of each store entry would be verified under + the CURRENT policy — computed with the same resolvers the install + gate enforces, so display and enforcement cannot disagree. The + trust-gate dialog replaces its generic "no per-entry status yet + (alpha)" note with the classified statement: signature checked + via pinned store key / trusted publishers (green), installs + without signature checking (amber, policy off), install would be + refused (red), or bridge entry without a bundle signature. Store + cards stay quiet on the good path and wear an "unsigned"/ + "blocked" warning pill only when installs are unverified — + same philosophy as the provenance pill. Against a pre-0.23 hub + everything keeps the old honest wording. +- **Service exposure on the doctor page.** Every declared host + service shows its network reach as classified by the hub from + the endpoint host (local only / private network / publicly + reachable / reach unknown — no DNS lookups). Publicly reachable + endpoints get the warning tone with a "make sure it is + protected" hint. + ### Added — explain it where it happens (0.76.0) - **In-place help pattern.** New `ChainInlineHelp` (a one-sentence diff --git a/integration_test/dialog_shots_test.dart b/integration_test/dialog_shots_test.dart index 7d70768..a7c2659 100644 --- a/integration_test/dialog_shots_test.dart +++ b/integration_test/dialog_shots_test.dart @@ -16,8 +16,10 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:integration_test/integration_test.dart'; import 'package:shared_preferences/shared_preferences.dart'; +import 'package:chain_studio/data/hub.dart' show StoreItem; import 'package:chain_studio/l10n/app_localizations.dart'; import 'package:chain_studio/pages/federation.dart'; +import 'package:chain_studio/widgets/chain_install_confirm.dart'; import '../test/support/fake_hub.dart'; @@ -41,6 +43,31 @@ Future _shot(WidgetTester tester, String name) async { print('dialog-shot: ${file.path}'); } +StoreItem _storeItem(String verification) => StoreItem( + name: 'text.extract', + taglineEn: 'Extract clean text', + taglineDe: 'Reinen Text extrahieren', + descriptionEn: '', + descriptionDe: '', + category: 'text', + tags: const [], + requiresCapabilities: const [], + requiresServices: const [], + license: 'Apache-2.0', + repository: '', + bestVersion: '0.1.0', + status: 'alpha', + installed: false, + featured: false, + iconUrl: '', + screenshotUrls: const [], + docsUrl: '', + kind: 'native', + provider: '', + source: 'bundled', + installVerification: verification, +); + void main() { IntegrationTestWidgetsFlutterBinding.ensureInitialized(); @@ -76,4 +103,38 @@ void main() { findsOneWidget); }); } + + // Trust gate with the hub-reported verification statement — the + // pinned-key (good) and policy-off (warning) variants. + for (final (name, verification) in [ + ('trust-gate-pinned', 'pinned-key'), + ('trust-gate-unverified', 'unverified'), + ]) { + testWidgets('$name — dark', (tester) async { + SharedPreferences.setMockInitialValues({}); + installFakeHub(); + await tester.pumpWidget( + MaterialApp( + debugShowCheckedModeBanner: false, + themeMode: ThemeMode.dark, + theme: ThemeData.light(useMaterial3: true), + darkTheme: ThemeData.dark(useMaterial3: true), + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + locale: const Locale('de'), + builder: (context, child) => + RepaintBoundary(key: _shotKey, child: child), + home: Scaffold( + body: Center( + child: ChainInstallConfirmDialog( + item: _storeItem(verification), + ), + ), + ), + ), + ); + await tester.pump(const Duration(milliseconds: 200)); + await _shot(tester, '$name-dark'); + }); + } } diff --git a/lib/data/about_info.dart b/lib/data/about_info.dart index f867338..1371790 100644 --- a/lib/data/about_info.dart +++ b/lib/data/about_info.dart @@ -4,7 +4,7 @@ /// Studio's own build version. Bump on every UI release so the /// running app self-identifies. -const String kStudioVersion = '0.76.0'; +const String kStudioVersion = '0.77.0'; const String kProductName = 'Ch∆In Studio'; const String kVendorName = 'Flemming.AI (F∆I)'; diff --git a/lib/data/hub.dart b/lib/data/hub.dart index 8446677..11a0cac 100644 --- a/lib/data/hub.dart +++ b/lib/data/hub.dart @@ -937,6 +937,7 @@ class HubService { source: e.source, canonicalCategory: e.canonicalCategory, canonicalCategoryLabel: e.canonicalCategoryLabel, + installVerification: e.installVerification, ), ) .toList(); @@ -1289,7 +1290,12 @@ class HubService { services: services .map( (s) => - ServiceEntry(name: s.name, endpoint: s.endpoint, tags: s.tags), + ServiceEntry( + name: s.name, + endpoint: s.endpoint, + tags: s.tags, + exposure: s.exposure, + ), ) .toList(), update: UpdateStatus( @@ -1386,10 +1392,16 @@ class ServiceEntry { final String endpoint; final List tags; + /// Network reach of [endpoint] as classified by the hub + /// (host part only, no DNS): "loopback" / "private" / "public" / + /// "unknown". Empty from pre-0.23 hubs. + final String exposure; + const ServiceEntry({ required this.name, required this.endpoint, required this.tags, + this.exposure = '', }); } @@ -2233,6 +2245,15 @@ class StoreItem { /// Human-readable label for [canonicalCategory] (e.g. "Data & Formats"). final String canonicalCategoryLabel; + /// How an install of this entry would be verified under the + /// hub's CURRENT policy — computed hub-side with the same + /// resolvers the install gate enforces, so this can never + /// disagree with what actually happens: + /// "pinned-key" / "trusted-publishers" / "unverified" / + /// "blocked" / "federated". Empty from pre-0.23 hubs — the UI + /// keeps its generic note then. + final String installVerification; + /// True iff this entry is a Studio plugin/theme rather than a flow /// module — drives the "Modules | Studio & Themes" store segment. bool get isStudioPlugin => canonicalCategory == 'studio-themes'; @@ -2264,5 +2285,6 @@ class StoreItem { this.source = '', this.canonicalCategory = '', this.canonicalCategoryLabel = '', + this.installVerification = '', }); } diff --git a/lib/data/install_verification.dart b/lib/data/install_verification.dart new file mode 100644 index 0000000..e3bc4b9 --- /dev/null +++ b/lib/data/install_verification.dart @@ -0,0 +1,65 @@ +// Presentation of `StoreItem.installVerification` — the hub's +// per-entry statement of how an install would be verified under +// the CURRENT policy (computed hub-side with the same resolvers +// the install gate enforces). One mapping, used by the trust-gate +// dialog, the module detail sheet and the store card, so every +// surface tells the same story. + +import '../l10n/app_localizations.dart'; + +enum VerificationTone { good, neutral, warning, danger } + +class InstallVerificationInfo { + final String label; + final String body; + final VerificationTone tone; + + const InstallVerificationInfo({ + required this.label, + required this.body, + required this.tone, + }); +} + +/// Maps the wire value to its localized presentation. Returns null +/// for an empty/unknown value (pre-0.23 hub) — callers keep their +/// generic wording then instead of guessing. +InstallVerificationInfo? describeInstallVerification( + String wire, + AppLocalizations l, +) { + switch (wire) { + case 'pinned-key': + return InstallVerificationInfo( + label: l.verifPinnedKey, + body: l.verifPinnedKeyBody, + tone: VerificationTone.good, + ); + case 'trusted-publishers': + return InstallVerificationInfo( + label: l.verifTrustedPublishers, + body: l.verifTrustedPublishersBody, + tone: VerificationTone.good, + ); + case 'unverified': + return InstallVerificationInfo( + label: l.verifUnverified, + body: l.verifUnverifiedBody, + tone: VerificationTone.warning, + ); + case 'blocked': + return InstallVerificationInfo( + label: l.verifBlocked, + body: l.verifBlockedBody, + tone: VerificationTone.danger, + ); + case 'federated': + return InstallVerificationInfo( + label: l.verifFederated, + body: l.verifFederatedBody, + tone: VerificationTone.neutral, + ); + default: + return null; + } +} diff --git a/lib/l10n/app_de.arb b/lib/l10n/app_de.arb index 822947f..89dcfb9 100644 --- a/lib/l10n/app_de.arb +++ b/lib/l10n/app_de.arb @@ -1189,6 +1189,12 @@ } }, "doctorSummaryDeclared": "deklariert", + "svcExposureLoopback": "nur lokal", + "svcExposurePrivate": "privates Netz", + "svcExposurePublic": "öffentlich erreichbar", + "svcExposureUnknown": "Reichweite unbekannt", + "svcExposurePublicHint": "Dieser Dienst-Endpunkt liegt auf einer öffentlichen Adresse — prüfen Sie, ob er abgesichert ist (TLS, Auth, Firewall).", + "svcExposureUnknownHint": "Hostname statt IP-Adresse — der Hub löst zur Einstufung bewusst keine Namen auf.", "doctorLinkStore": "Store öffnen", "doctorLinkApprovals": "Freigaben öffnen", "doctorLinkAudit": "Protokoll öffnen", @@ -1622,6 +1628,19 @@ "installConfirmNeedsCapabilities": "Benötigte Fähigkeiten", "installConfirmTrustTitle": "Vertrauen & Sicherheit", "installConfirmTrustBody": "Das Modul läuft in einer Sandbox: Es darf nur auf die Netzwerk-Endpunkte, Dateien und Umgebungsvariablen zugreifen, die es selbst deklariert — der Hub setzt diese Liste durch. Die vollständige Berechtigungsliste sehen Sie nach der Installation in den Modul-Details.", + "storePolicyUnverifiedNotice": "Die Signaturpflicht ist in der Hub-Richtlinie ausgeschaltet — Installationen werden nicht kryptografisch geprüft. Der Installations-Dialog zeigt den Status je Modul; für geprüfte Installationen security.require_signatures aktivieren.", + "verifPillUnverified": "unsigniert", + "verifPillBlocked": "blockiert", + "verifPinnedKey": "Signatur wird geprüft — hinterlegter Store-Schlüssel", + "verifPinnedKeyBody": "Der Hub verifiziert das Paket beim Installieren gegen den fest hinterlegten Schlüssel dieser Quelle. Ein manipuliertes Paket wird abgelehnt.", + "verifTrustedPublishers": "Signatur wird geprüft — vertrauenswürdige Herausgeber", + "verifTrustedPublishersBody": "Der Hub verifiziert die Signatur beim Installieren gegen die Liste vertrauenswürdiger Herausgeber. Ein Paket ohne gültige Signatur wird abgelehnt.", + "verifUnverified": "Wird ohne Signaturprüfung installiert", + "verifUnverifiedBody": "Die Signaturpflicht ist in der Hub-Richtlinie ausgeschaltet — dieses Paket wird beim Installieren nicht kryptografisch geprüft. Für geprüfte Installationen security.require_signatures aktivieren.", + "verifBlocked": "Installation würde abgelehnt", + "verifBlockedBody": "Die Richtlinie verlangt Signaturen, aber für diese Quelle ist kein Schlüsselmaterial hinterlegt — der Hub würde die Installation verweigern.", + "verifFederated": "Über Integration angebunden — keine Paket-Signatur", + "verifFederatedBody": "Dieser Eintrag ist kein installierbares Paket, sondern läuft über eine konfigurierte Integration (z. B. MCP oder n8n). Eine Paket-Signatur gibt es hier nicht; es gilt das Vertrauen in die Integration.", "installConfirmSignatureNote": "Signaturen prüft der Hub beim Installieren, sofern die Signaturpflicht im Sicherheitsprofil aktiv ist. Einen Signaturstatus vorab zeigt der Store-Index derzeit noch nicht an (Alpha).", "settingsSidebarPinnedTitle": "Navigation immer ausgeklappt", "settingsSidebarPinnedBody": "Zeigt die Beschriftungen der Seitenleiste dauerhaft an, statt sie nur beim Überfahren mit der Maus einzublenden.", diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 874e9ac..98787fc 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -1213,6 +1213,12 @@ } }, "doctorSummaryDeclared": "declared", + "svcExposureLoopback": "local only", + "svcExposurePrivate": "private network", + "svcExposurePublic": "publicly reachable", + "svcExposureUnknown": "reach unknown", + "svcExposurePublicHint": "This service endpoint sits on a public address — make sure it is protected (TLS, auth, firewall).", + "svcExposureUnknownHint": "Hostname instead of an IP address — the hub deliberately does not resolve names to classify.", "doctorLinkStore": "Open the store", "doctorLinkApprovals": "Open approvals", "doctorLinkAudit": "Open the audit log", @@ -1646,6 +1652,19 @@ "installConfirmNeedsCapabilities": "Required capabilities", "installConfirmTrustTitle": "Trust & security", "installConfirmTrustBody": "The module runs in a sandbox: it may only touch the network endpoints, files, and environment variables it declares itself — the hub enforces that list. The full permission list is visible in the module details after installation.", + "storePolicyUnverifiedNotice": "Signature enforcement is switched off in the hub policy — installs are not cryptographically verified. The install dialog shows the per-module status; enable security.require_signatures for verified installs.", + "verifPillUnverified": "unsigned", + "verifPillBlocked": "blocked", + "verifPinnedKey": "Signature checked — pinned store key", + "verifPinnedKeyBody": "The hub verifies the bundle at install time against this source's pinned key. A tampered bundle is refused.", + "verifTrustedPublishers": "Signature checked — trusted publishers", + "verifTrustedPublishersBody": "The hub verifies the signature at install time against the trusted-publisher list. A bundle without a valid signature is refused.", + "verifUnverified": "Installs without signature checking", + "verifUnverifiedBody": "Signature enforcement is switched off in the hub policy — this bundle is not cryptographically verified at install time. Enable security.require_signatures for verified installs.", + "verifBlocked": "An install would be refused", + "verifBlockedBody": "The policy requires signatures, but no key material applies to this source — the hub would refuse the install.", + "verifFederated": "Connected via an integration — no bundle signature", + "verifFederatedBody": "This entry is not an installable bundle; it runs through a configured integration (e.g. MCP or n8n). There is no bundle signature — trust follows the integration.", "installConfirmSignatureNote": "The hub verifies signatures at install time whenever the security profile requires them. The store index does not yet show a per-entry signature status up front (alpha).", "settingsSidebarPinnedTitle": "Keep the navigation expanded", "settingsSidebarPinnedBody": "Shows the sidebar labels permanently instead of only while hovering with the mouse.", diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index c3c747f..bb0796a 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -3692,6 +3692,42 @@ abstract class AppLocalizations { /// **'declared'** String get doctorSummaryDeclared; + /// No description provided for @svcExposureLoopback. + /// + /// In en, this message translates to: + /// **'local only'** + String get svcExposureLoopback; + + /// No description provided for @svcExposurePrivate. + /// + /// In en, this message translates to: + /// **'private network'** + String get svcExposurePrivate; + + /// No description provided for @svcExposurePublic. + /// + /// In en, this message translates to: + /// **'publicly reachable'** + String get svcExposurePublic; + + /// No description provided for @svcExposureUnknown. + /// + /// In en, this message translates to: + /// **'reach unknown'** + String get svcExposureUnknown; + + /// No description provided for @svcExposurePublicHint. + /// + /// In en, this message translates to: + /// **'This service endpoint sits on a public address — make sure it is protected (TLS, auth, firewall).'** + String get svcExposurePublicHint; + + /// No description provided for @svcExposureUnknownHint. + /// + /// In en, this message translates to: + /// **'Hostname instead of an IP address — the hub deliberately does not resolve names to classify.'** + String get svcExposureUnknownHint; + /// No description provided for @doctorLinkStore. /// /// In en, this message translates to: @@ -4898,6 +4934,84 @@ abstract class AppLocalizations { /// **'The module runs in a sandbox: it may only touch the network endpoints, files, and environment variables it declares itself — the hub enforces that list. The full permission list is visible in the module details after installation.'** String get installConfirmTrustBody; + /// No description provided for @storePolicyUnverifiedNotice. + /// + /// In en, this message translates to: + /// **'Signature enforcement is switched off in the hub policy — installs are not cryptographically verified. The install dialog shows the per-module status; enable security.require_signatures for verified installs.'** + String get storePolicyUnverifiedNotice; + + /// No description provided for @verifPillUnverified. + /// + /// In en, this message translates to: + /// **'unsigned'** + String get verifPillUnverified; + + /// No description provided for @verifPillBlocked. + /// + /// In en, this message translates to: + /// **'blocked'** + String get verifPillBlocked; + + /// No description provided for @verifPinnedKey. + /// + /// In en, this message translates to: + /// **'Signature checked — pinned store key'** + String get verifPinnedKey; + + /// No description provided for @verifPinnedKeyBody. + /// + /// In en, this message translates to: + /// **'The hub verifies the bundle at install time against this source\'s pinned key. A tampered bundle is refused.'** + String get verifPinnedKeyBody; + + /// No description provided for @verifTrustedPublishers. + /// + /// In en, this message translates to: + /// **'Signature checked — trusted publishers'** + String get verifTrustedPublishers; + + /// No description provided for @verifTrustedPublishersBody. + /// + /// In en, this message translates to: + /// **'The hub verifies the signature at install time against the trusted-publisher list. A bundle without a valid signature is refused.'** + String get verifTrustedPublishersBody; + + /// No description provided for @verifUnverified. + /// + /// In en, this message translates to: + /// **'Installs without signature checking'** + String get verifUnverified; + + /// No description provided for @verifUnverifiedBody. + /// + /// In en, this message translates to: + /// **'Signature enforcement is switched off in the hub policy — this bundle is not cryptographically verified at install time. Enable security.require_signatures for verified installs.'** + String get verifUnverifiedBody; + + /// No description provided for @verifBlocked. + /// + /// In en, this message translates to: + /// **'An install would be refused'** + String get verifBlocked; + + /// No description provided for @verifBlockedBody. + /// + /// In en, this message translates to: + /// **'The policy requires signatures, but no key material applies to this source — the hub would refuse the install.'** + String get verifBlockedBody; + + /// No description provided for @verifFederated. + /// + /// In en, this message translates to: + /// **'Connected via an integration — no bundle signature'** + String get verifFederated; + + /// No description provided for @verifFederatedBody. + /// + /// In en, this message translates to: + /// **'This entry is not an installable bundle; it runs through a configured integration (e.g. MCP or n8n). There is no bundle signature — trust follows the integration.'** + String get verifFederatedBody; + /// No description provided for @installConfirmSignatureNote. /// /// In en, this message translates to: diff --git a/lib/l10n/app_localizations_de.dart b/lib/l10n/app_localizations_de.dart index a72ae77..6670717 100644 --- a/lib/l10n/app_localizations_de.dart +++ b/lib/l10n/app_localizations_de.dart @@ -2149,6 +2149,26 @@ class AppLocalizationsDe extends AppLocalizations { @override String get doctorSummaryDeclared => 'deklariert'; + @override + String get svcExposureLoopback => 'nur lokal'; + + @override + String get svcExposurePrivate => 'privates Netz'; + + @override + String get svcExposurePublic => 'öffentlich erreichbar'; + + @override + String get svcExposureUnknown => 'Reichweite unbekannt'; + + @override + String get svcExposurePublicHint => + 'Dieser Dienst-Endpunkt liegt auf einer öffentlichen Adresse — prüfen Sie, ob er abgesichert ist (TLS, Auth, Firewall).'; + + @override + String get svcExposureUnknownHint => + 'Hostname statt IP-Adresse — der Hub löst zur Einstufung bewusst keine Namen auf.'; + @override String get doctorLinkStore => 'Store öffnen'; @@ -2886,6 +2906,54 @@ class AppLocalizationsDe extends AppLocalizations { String get installConfirmTrustBody => 'Das Modul läuft in einer Sandbox: Es darf nur auf die Netzwerk-Endpunkte, Dateien und Umgebungsvariablen zugreifen, die es selbst deklariert — der Hub setzt diese Liste durch. Die vollständige Berechtigungsliste sehen Sie nach der Installation in den Modul-Details.'; + @override + String get storePolicyUnverifiedNotice => + 'Die Signaturpflicht ist in der Hub-Richtlinie ausgeschaltet — Installationen werden nicht kryptografisch geprüft. Der Installations-Dialog zeigt den Status je Modul; für geprüfte Installationen security.require_signatures aktivieren.'; + + @override + String get verifPillUnverified => 'unsigniert'; + + @override + String get verifPillBlocked => 'blockiert'; + + @override + String get verifPinnedKey => + 'Signatur wird geprüft — hinterlegter Store-Schlüssel'; + + @override + String get verifPinnedKeyBody => + 'Der Hub verifiziert das Paket beim Installieren gegen den fest hinterlegten Schlüssel dieser Quelle. Ein manipuliertes Paket wird abgelehnt.'; + + @override + String get verifTrustedPublishers => + 'Signatur wird geprüft — vertrauenswürdige Herausgeber'; + + @override + String get verifTrustedPublishersBody => + 'Der Hub verifiziert die Signatur beim Installieren gegen die Liste vertrauenswürdiger Herausgeber. Ein Paket ohne gültige Signatur wird abgelehnt.'; + + @override + String get verifUnverified => 'Wird ohne Signaturprüfung installiert'; + + @override + String get verifUnverifiedBody => + 'Die Signaturpflicht ist in der Hub-Richtlinie ausgeschaltet — dieses Paket wird beim Installieren nicht kryptografisch geprüft. Für geprüfte Installationen security.require_signatures aktivieren.'; + + @override + String get verifBlocked => 'Installation würde abgelehnt'; + + @override + String get verifBlockedBody => + 'Die Richtlinie verlangt Signaturen, aber für diese Quelle ist kein Schlüsselmaterial hinterlegt — der Hub würde die Installation verweigern.'; + + @override + String get verifFederated => + 'Über Integration angebunden — keine Paket-Signatur'; + + @override + String get verifFederatedBody => + 'Dieser Eintrag ist kein installierbares Paket, sondern läuft über eine konfigurierte Integration (z. B. MCP oder n8n). Eine Paket-Signatur gibt es hier nicht; es gilt das Vertrauen in die Integration.'; + @override String get installConfirmSignatureNote => 'Signaturen prüft der Hub beim Installieren, sofern die Signaturpflicht im Sicherheitsprofil aktiv ist. Einen Signaturstatus vorab zeigt der Store-Index derzeit noch nicht an (Alpha).'; diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index 5e766b5..0da91f4 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -2153,6 +2153,26 @@ class AppLocalizationsEn extends AppLocalizations { @override String get doctorSummaryDeclared => 'declared'; + @override + String get svcExposureLoopback => 'local only'; + + @override + String get svcExposurePrivate => 'private network'; + + @override + String get svcExposurePublic => 'publicly reachable'; + + @override + String get svcExposureUnknown => 'reach unknown'; + + @override + String get svcExposurePublicHint => + 'This service endpoint sits on a public address — make sure it is protected (TLS, auth, firewall).'; + + @override + String get svcExposureUnknownHint => + 'Hostname instead of an IP address — the hub deliberately does not resolve names to classify.'; + @override String get doctorLinkStore => 'Open the store'; @@ -2882,6 +2902,52 @@ class AppLocalizationsEn extends AppLocalizations { String get installConfirmTrustBody => 'The module runs in a sandbox: it may only touch the network endpoints, files, and environment variables it declares itself — the hub enforces that list. The full permission list is visible in the module details after installation.'; + @override + String get storePolicyUnverifiedNotice => + 'Signature enforcement is switched off in the hub policy — installs are not cryptographically verified. The install dialog shows the per-module status; enable security.require_signatures for verified installs.'; + + @override + String get verifPillUnverified => 'unsigned'; + + @override + String get verifPillBlocked => 'blocked'; + + @override + String get verifPinnedKey => 'Signature checked — pinned store key'; + + @override + String get verifPinnedKeyBody => + 'The hub verifies the bundle at install time against this source\'s pinned key. A tampered bundle is refused.'; + + @override + String get verifTrustedPublishers => 'Signature checked — trusted publishers'; + + @override + String get verifTrustedPublishersBody => + 'The hub verifies the signature at install time against the trusted-publisher list. A bundle without a valid signature is refused.'; + + @override + String get verifUnverified => 'Installs without signature checking'; + + @override + String get verifUnverifiedBody => + 'Signature enforcement is switched off in the hub policy — this bundle is not cryptographically verified at install time. Enable security.require_signatures for verified installs.'; + + @override + String get verifBlocked => 'An install would be refused'; + + @override + String get verifBlockedBody => + 'The policy requires signatures, but no key material applies to this source — the hub would refuse the install.'; + + @override + String get verifFederated => + 'Connected via an integration — no bundle signature'; + + @override + String get verifFederatedBody => + 'This entry is not an installable bundle; it runs through a configured integration (e.g. MCP or n8n). There is no bundle signature — trust follows the integration.'; + @override String get installConfirmSignatureNote => 'The hub verifies signatures at install time whenever the security profile requires them. The store index does not yet show a per-entry signature status up front (alpha).'; diff --git a/lib/pages/doctor.dart b/lib/pages/doctor.dart index 84a07e3..431c71c 100644 --- a/lib/pages/doctor.dart +++ b/lib/pages/doctor.dart @@ -1159,6 +1159,9 @@ class _ServicesPanel extends StatelessWidget { ), ), const Spacer(), + _ServiceExposurePill( + exposure: snapshot.services[i].exposure, + ), for (final tag in snapshot.services[i].tags) ...[ ChainPill(label: tag, tone: ChainPillTone.neutral), const SizedBox(width: ChainSpace.xs), @@ -1173,6 +1176,43 @@ class _ServicesPanel extends StatelessWidget { } } +/// Network-reach pill per declared service, from the hub's +/// endpoint classification (`DeclaredService.exposure`). Public +/// endpoints get the warning tone — a host service reachable from +/// outside is the one thing an operator should notice here. +/// Empty (pre-0.23 hub) renders nothing. +class _ServiceExposurePill extends StatelessWidget { + final String exposure; + + const _ServiceExposurePill({required this.exposure}); + + @override + Widget build(BuildContext context) { + final l = AppLocalizations.of(context)!; + final (label, tone, hint) = switch (exposure) { + 'loopback' => (l.svcExposureLoopback, ChainPillTone.neutral, ''), + 'private' => (l.svcExposurePrivate, ChainPillTone.neutral, ''), + 'public' => ( + l.svcExposurePublic, + ChainPillTone.warning, + l.svcExposurePublicHint, + ), + 'unknown' => ( + l.svcExposureUnknown, + ChainPillTone.neutral, + l.svcExposureUnknownHint, + ), + _ => ('', ChainPillTone.neutral, ''), + }; + if (label.isEmpty) return const SizedBox.shrink(); + final pill = ChainPill(label: label, tone: tone); + return Padding( + padding: const EdgeInsets.only(right: ChainSpace.xs), + child: hint.isEmpty ? pill : Tooltip(message: hint, child: pill), + ); + } +} + class _UpdateBanner extends StatefulWidget { final UpdateStatus status; diff --git a/lib/pages/store.dart b/lib/pages/store.dart index 7776fc0..0f81bed 100644 --- a/lib/pages/store.dart +++ b/lib/pages/store.dart @@ -14,6 +14,7 @@ import 'package:flutter_markdown_plus/flutter_markdown_plus.dart'; import '../data/error_presentation.dart'; import '../data/hub.dart'; +import '../data/install_verification.dart'; import '../data/system_actions.dart'; import '../data/today_story_loader.dart'; import '../l10n/app_localizations.dart'; @@ -383,6 +384,23 @@ class _StorePageState extends State { setState(() => _showStudio = v), ), ), + // ONE page-level notice when the hub says + // installs run unverified (policy off) — + // a global fact, so it appears once here + // instead of as a pill on every card. Old + // hubs report nothing => nothing shows. + if (raw.any( + (i) => i.installVerification == 'unverified', + )) + Padding( + padding: const EdgeInsets.only( + bottom: ChainSpace.md, + ), + child: ChainInlineHelp( + icon: Icons.gpp_maybe_outlined, + text: l.storePolicyUnverifiedNotice, + ), + ), if (_aiThinking || _aiAnswer != null || _aiError != null) ...[ @@ -2136,6 +2154,7 @@ class _StoreCardState extends State<_StoreCard> { const SizedBox(width: ChainSpace.xs), ], _ProvenancePill(item: item), + _VerificationPill(item: item), const Spacer(), if (_hasUpdate) FilledButton.icon( @@ -3809,6 +3828,39 @@ class _ProvenancePill extends StatelessWidget { } } +/// Signature-verification warning pill. Same philosophy as the +/// provenance pill: the GOOD path (signature checked at install) +/// stays quiet, and so does the policy-off case — that one is a +/// GLOBAL property and lives as ONE notice above the grid, not as +/// a repeated badge on every card. Only "blocked" (this source +/// has no applicable key material although the policy demands +/// signatures) is a genuine per-source anomaly worth a badge. +class _VerificationPill extends StatelessWidget { + final StoreItem item; + + const _VerificationPill({required this.item}); + + @override + Widget build(BuildContext context) { + final l = AppLocalizations.of(context)!; + if (item.installVerification != 'blocked') { + return const SizedBox.shrink(); + } + final info = describeInstallVerification(item.installVerification, l)!; + return Padding( + padding: const EdgeInsets.only(left: ChainSpace.xs), + child: Tooltip( + message: info.body, + child: ChainPill( + label: l.verifPillBlocked, + tone: ChainPillTone.danger, + icon: Icons.gpp_bad_outlined, + ), + ), + ); + } +} + /// Store-side version picker. Same shape as the module-sheet /// version picker but lives here so the Store page doesn't take /// a dependency on the module-sheet's private widgets. The diff --git a/lib/widgets/chain_install_confirm.dart b/lib/widgets/chain_install_confirm.dart index b2c33e7..00bad65 100644 --- a/lib/widgets/chain_install_confirm.dart +++ b/lib/widgets/chain_install_confirm.dart @@ -5,14 +5,16 @@ // quick fix) now routes through this dialog, which shows what // the hub actually knows before anything is downloaded — origin // store, version, license, maturity, required services — plus -// an honest note on when signature verification happens. No -// fake "verified" badge: per-entry signature status is not in -// the store index yet, and the dialog says so instead of -// pretending. +// the per-entry verification statement the hub computes with the +// SAME resolvers the install gate enforces (pinned key / trusted +// publishers / policy off / blocked / federated). Against a +// pre-0.23 hub the field is empty and the dialog keeps its +// generic when-verification-happens note instead of guessing. import 'package:flutter/material.dart'; import '../data/hub.dart'; +import '../data/install_verification.dart'; import '../l10n/app_localizations.dart'; import '../theme/tokens.dart'; @@ -184,12 +186,65 @@ class ChainInstallConfirmDialog extends StatelessWidget { ), ), const SizedBox(height: ChainSpace.xs), - Text( - l.installConfirmSignatureNote, - style: theme.textTheme.bodySmall?.copyWith( - color: theme.colorScheme.onSurfaceVariant, - height: 1.4, - ), + Builder( + builder: (context) { + final info = describeInstallVerification( + item.installVerification, + l, + ); + if (info == null) { + // Pre-0.23 hub: keep the generic note. + return Text( + l.installConfirmSignatureNote, + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + height: 1.4, + ), + ); + } + final color = switch (info.tone) { + VerificationTone.good => ChainColors.success, + VerificationTone.warning => ChainColors.warning, + VerificationTone.danger => theme.colorScheme.error, + VerificationTone.neutral => + theme.colorScheme.onSurfaceVariant, + }; + final icon = switch (info.tone) { + VerificationTone.good => Icons.verified_outlined, + VerificationTone.warning => + Icons.warning_amber_outlined, + VerificationTone.danger => Icons.gpp_bad_outlined, + VerificationTone.neutral => Icons.link_outlined, + }; + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon(icon, size: 14, color: color), + const SizedBox(width: 6), + Expanded( + child: Text( + info.label, + style: theme.textTheme.bodySmall?.copyWith( + color: color, + fontWeight: FontWeight.w600, + ), + ), + ), + ], + ), + const SizedBox(height: 2), + Text( + info.body, + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + height: 1.4, + ), + ), + ], + ); + }, ), ], ), diff --git a/pubspec.yaml b/pubspec.yaml index c6dba7b..7dcbc3d 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,7 +1,7 @@ name: chain_studio description: "Ch∆In Studio — desktop GUI for the Ch∆In hub" publish_to: 'none' -version: 0.76.0 +version: 0.77.0 environment: sdk: ^3.11.0-200.1.beta diff --git a/test/install_verification_test.dart b/test/install_verification_test.dart new file mode 100644 index 0000000..9ad48bd --- /dev/null +++ b/test/install_verification_test.dart @@ -0,0 +1,116 @@ +// Presentation of the hub's per-entry install-verification +// statement. The mapping is the single source for the trust-gate +// dialog, the store card pill and the detail sheet — these tests +// pin the wire contract and the honest-fallback rule (unknown or +// empty => null => callers keep their generic wording). + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'package:chain_studio/data/hub.dart'; +import 'package:chain_studio/data/install_verification.dart'; +import 'package:chain_studio/l10n/app_localizations.dart'; +import 'package:chain_studio/widgets/chain_install_confirm.dart'; + +StoreItem _item({String verification = ''}) => StoreItem( + name: 'text.extract', + taglineEn: '', + taglineDe: '', + descriptionEn: '', + descriptionDe: '', + category: 'text', + tags: const [], + requiresCapabilities: const [], + requiresServices: const [], + license: 'Apache-2.0', + repository: '', + bestVersion: '0.1.0', + status: 'alpha', + installed: false, + featured: false, + iconUrl: '', + screenshotUrls: const [], + docsUrl: '', + kind: 'native', + provider: '', + source: 'bundled', + installVerification: verification, +); + +Widget _host(Widget child) => MaterialApp( + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + locale: const Locale('de'), + home: Scaffold(body: child), +); + +Future _l10n(WidgetTester tester) async { + late AppLocalizations l; + await tester.pumpWidget( + _host( + Builder( + builder: (context) { + l = AppLocalizations.of(context)!; + return const SizedBox(); + }, + ), + ), + ); + return l; +} + +void main() { + testWidgets('wire values map to tones; unknown maps to null', ( + tester, + ) async { + final l = await _l10n(tester); + expect( + describeInstallVerification('pinned-key', l)!.tone, + VerificationTone.good, + ); + expect( + describeInstallVerification('trusted-publishers', l)!.tone, + VerificationTone.good, + ); + expect( + describeInstallVerification('unverified', l)!.tone, + VerificationTone.warning, + ); + expect( + describeInstallVerification('blocked', l)!.tone, + VerificationTone.danger, + ); + expect( + describeInstallVerification('federated', l)!.tone, + VerificationTone.neutral, + ); + // Pre-0.23 hub / future value: no guessing. + expect(describeInstallVerification('', l), isNull); + expect(describeInstallVerification('something-new', l), isNull); + }); + + testWidgets('trust gate states the classified verification', (tester) async { + await tester.pumpWidget( + _host(ChainInstallConfirmDialog(item: _item(verification: 'unverified'))), + ); + await tester.pumpAndSettle(); + expect( + find.text('Wird ohne Signaturprüfung installiert'), + findsOneWidget, + ); + // The generic alpha note must be gone when the hub reports. + expect( + find.textContaining('zeigt der Store-Index derzeit noch nicht'), + findsNothing, + ); + }); + + testWidgets('trust gate keeps the generic note for old hubs', (tester) async { + await tester.pumpWidget(_host(ChainInstallConfirmDialog(item: _item()))); + await tester.pumpAndSettle(); + expect( + find.textContaining('zeigt der Store-Index derzeit noch nicht'), + findsOneWidget, + ); + }); +}