Repository navigation
Plugin transport: scoped TLS certificate TOFU and explicit trust reset #979
Description
Local devices and private services can expose TLS without a certificate trusted by the platform. A plugin currently has no supported way to remember a user-approved server certificate. Add host-managed trust on first use (TOFU) to raw TLS and secure WebSocket transports: the user approves the first certificate, Decaid remembers it for that plugin and endpoint, and any later certificate change stops the connection.
This is a proposal for review, not an accepted or implemented contract. It complements #758, which covers custom CAs and client certificates. It builds on the outbound transport API from #146/#759. Inbound HTTPS configuration in #925 is a separate concern.
User experience
- When setting up a local device, I can review its certificate in native Decaid settings and approve it once, without having to enter a fingerprint first.
- When the same device reconnects, Decaid checks the saved certificate before sending credentials or device commands.
- If the certificate changes, I see why the connection stopped. I can inspect the change and deliberately reset trust for that device endpoint before enrolling it again.
- If I already have a fingerprint from a trusted source, I can enter it instead of using TOFU.
Normal platform certificate validation remains the default. TOFU is the default enrollment choice after custom server trust has been explicitly selected; a failed platform-trust connection must never silently switch modes.
Trust policy and scope
Use SHA-256 of the complete DER-encoded leaf certificate. Store the approved fingerprint in host-owned storage, outside plugin-writable settings. No certificate import, private keys, CA profiles, mTLS, global certificate callback, or accept-all option is needed for this proposal.
A record belongs to (plugin id, transport kind, scheme, canonical host, effective port, endpoint path). Use the authenticated plugin identity, never an identity supplied in transport options. The record survives ordinary plugin reloads and app restarts; transport handles and pending operations remain generation-owned. Uninstall removes the plugin's records so reinstalling cannot inherit old approval.
Normalization must be shared by lookup, enrollment, connection and reset:
- Normalize DNS names and IP literals consistently with the actual connector; never key trust by a DNS-resolved address or reverse lookup. Specify and test IDNA, IPv6 and trailing-dot handling.
- Normalize scheme/host casing and effective ports. Raw TLS has no path; WSS defaults an empty path to
/. Preserve path case and encoded separators; do not collapse distinct server paths through decoding. - Exclude query strings from stored scope because they can contain credentials. Reject URL user information and fragments in custom-trust mode. A changed token must not require enrollment again.
- Different plugins, schemes, transport kinds, hosts, ports or paths have separate records. WSS approval does not grant raw TLS or HTTPS/fetch approval.
- Reject malformed endpoints/options, stale generations and missing permissions before DNS or connect. Keep existing transport permissions:
network.tlsfor raw TLS andnetwork.websocketfor WSS.
Pins replace the need for a platform-trusted issuer only for this connection. They do not waive hostname/IP SAN matching, certificate validity dates, or TLS protocol checks. An approved but expired or wrong-host certificate still fails. A device whose certificate cannot satisfy those checks needs a separate design decision; this proposal does not silently exempt it.
TOFU does not authenticate the first peer by itself. The native approval screen must explain that a fingerprint should be checked through an independent trusted source where available, and that an attacker present during first enrollment could otherwise be remembered. Local-network proximity alone is not proof of identity. Manual pinning is useful only when the supplied fingerprint came from a trusted source.
Plugin contract and native enrollment
Proposed additive option, with final naming subject to maintainer review:
const opened = await host.transport.open({
kind: "websocket",
url: "wss://device.example:9997/events?token=...",
tlsTrust: "storedIdentity"
});The same option applies to kind: "tls" and to the transport supplied to a device driver's connect invocation. Omission means platform trust. Unknown values and use on plaintext ws/tcp fail as invalid options. The plugin cannot provide a fingerprint, approve enrollment, replace a record, or reset trust through this API; TOFU versus manual entry is a native enrollment choice for the same stored identity.
For a missing record, open() rejects with transport_trust_required. It does not open a prompt or queue application data. Native plugin/device settings offer Review server certificate, with Enter fingerprint as the optional alternative. Only an explicit foreground user action starts enrollment; background retry merely exposes the actionable status.
The enrollment action performs a bounded TLS-only probe, obtains the leaf certificate and closes the probe connection. It must not send a WebSocket upgrade, HTTP request, token, protocol login or other application bytes. Decaid displays the plugin, host/port, safe endpoint context, certificate fingerprint, validity and name-validation result. Invalid certificates cannot be approved under this policy. On approval, atomically persist the exact candidate and scope, then establish a new connection and check the certificate again. A changed peer between probe and reconnect fails; it is never accepted based on the earlier probe.
Manual entry accepts a normalized 32-byte SHA-256 fingerprint, rejects malformed input, and uses the same connection checks. All approval actions are bound to the candidate, plugin generation, endpoint and trust epoch; a stale dialog cannot approve a newer connection or undo a reset. Bound the number and lifetime of probes/dialogs using the existing resource-limit approach.
open() resolves only after trust verification and connection establishment. For WSS, trust must be checked before the HTTP upgrade request, including its token-bearing query string; waiting until the first WebSocket frame is too late. For raw TLS, no application write or data callback is released before verification. TLS control traffic needed to obtain a certificate is allowed; TLS early application data is not.
Every custom-trust connection must check the pin, including certificates already trusted by the platform. Checking only onBadCertificate/badCertificateCallback is insufficient: those callbacks handle otherwise untrusted certificates and do not enforce a pin on all connections. Do not reuse a connection or resumed TLS session unless the adapter proves the same scope, fingerprint and current epoch are enforced; disabling such reuse is the safe first implementation.
Custom-trust WSS must reject redirects in v1 rather than forward a token to another authority/path or fall back to plaintext. A user can configure and separately enroll the intended endpoint instead.
This proposal covers host.transport, not plugin fetch/HTTP. Fetch has its own authorization, HTTP client and documented address restrictions. Adding TOFU here must not change them or imply that an E64 HTTPS request now works. An approved fetch capability would need its own contract, including redirects, credentials, pooling and private-address policy. Consumers must not build an ad hoc HTTP stack over raw TLS to bypass that boundary.
State and reset
flowchart TD
A[Open transport] --> B{Trust mode}
B -->|Platform| C[Normal platform validation]
B -->|Stored identity| D{Approved record exists?}
D -->|No| E[Reject: enrollment required]
E --> F[User starts native enrollment]
F --> G[TLS-only probe and certificate review]
G -->|Decline or cancel| E
G -->|Approve| H[Persist scoped fingerprint]
H --> I[Reconnect and verify current peer]
D -->|Yes| I
I -->|Matches and certificate valid| J[Open; allow application data]
I -->|Mismatch or invalid certificate| K[Reject; retain approved record]
K --> L[User inspects failure]
L -->|Explicit reset| M[Block scope; increment epoch; disconnect; delete record]
M --> E
Stored trust is either absent or approved; enrollment pending and certificate changed are connection/UI states, not permission to overwrite a record. Decline, timeout and ordinary network failure do not mutate trust. A certificate renewal also changes a leaf pin, even if the public key is unchanged, and needs explicit reset and enrollment.
Reset trust is a dedicated native action showing the selected plugin and endpoint and warning that active connections will close. Once confirmed, block new opens for that scope, advance its epoch, cancel pending enrollment/connect work, disconnect matching transports and delete only that record. Complete reset only when invalidation and durable deletion succeed. Late completions, events, writes and approval clicks from older epochs must have no effect. Other plugins/endpoints keep their connections and trust.
If deletion fails, show the failure and keep the scope blocked for this process; do not claim reset succeeded. The persisted old record may remain after restart, which the UI must report truthfully. Corrupt or unreadable storage fails closed rather than appearing as first use. Loading storage must finish before opens can use stored identity.
Plugin unload/generation replacement and manager disposal cancel their probes, prompts, pending opens, sockets and listeners. Ordinary reload keeps the durable approval, but none of the old generation's authority. Uninstall must report a trust-cleanup failure rather than silently treating removal as complete. Trust records must not become transferable approvals through ordinary plugin export/import or backup restoration.
Error handling and recovery
Extend the existing structured transport errors rather than adding a second error channel. Pre-open failures reject the Promise with a stable code and safe message; post-open terminal failures emit an error and follow existing deterministic close/cleanup semantics. Preserve PluginPermissionError and transport_resource_limit. The names below are proposed additions, not current API claims.
| Failure | Plugin result | Native UI and retry behavior |
|---|---|---|
| Invalid mode, endpoint or fingerprint | transport_invalid_options |
Explain the bad setting; no network attempt. Fix configuration. |
| Missing permission | Existing PluginPermissionError |
Identify required permission; no network attempt. |
| Retired generation/handle, cancelled open | transport_cancelled |
Close/fence work; no prompt or automatic recreation of the retired operation. |
| Custom trust unavailable on this platform | transport_trust_unsupported |
Explain unsupported transport/platform; no downgrade or bypass. |
| No approved identity | transport_trust_required |
Offer foreground enrollment. Suspend automatic retry for this condition. |
| User declines, closes the dialog or approval times out | transport_trust_declined for the enrollment operation |
Close probe; save nothing. Retry enrollment only by explicit action. |
| Certificate differs from approved fingerprint | transport_certificate_changed |
Retain old record, show old/new fingerprints in native UI, stop retry loop; inspect then reset if intended. |
| Expired/not-yet-valid certificate, wrong host/IP, invalid certificate or TLS validation failure | transport_certificate_invalid or transport_tls_failed with a safe reason |
Retain record; explain certificate/clock/server problem. Reset alone is not a fix. |
| Trust storage read/write/delete failure | transport_trust_storage_failed |
Fail closed; show whether enrollment/reset failed; never announce success or silently create fresh trust. |
| Reset invalidates an open or pending connection | transport_trust_reset |
Stop connection/retry work; show enrollment required after successful reset. |
| DNS failure, refusal, connect timeout or transient I/O failure | transport_connect_failed / transport_io_failed with a safe reason |
Preserve trust; bounded backoff is allowed. A timeout waiting for a user is not a network retry. |
| Peer closes a healthy connection | Existing close event | Normal reconnect policy, with trust rechecked on the next connection. |
| Resource bound exceeded | Existing transport_resource_limit |
Release pending resources; do not open more probes/prompts in a loop. |
| Unexpected internal/native exception | Existing transport_error with a safe diagnostic id |
Close/fence work, preserve approved trust, and surface failure without raw exception text or a trust bypass. |
After trust succeeds, a WSS upgrade rejection or protocol authentication failure remains a connection/protocol error; it must not erase trust or invite certificate reset. Do not invent a certificate explanation for an unclassified native TLS failure. Give it a safe diagnostic category and retain the record.
Native UI owns approval and reset. Plugins and skins may show safe status and point users to that UI, but cannot manufacture approval. Use accessible labels, selectable fingerprints and clear actions: Retry for transient failures, Review certificate for first use, Inspect / Reset trust for a certificate change. There is no one-click "continue anyway" on mismatch.
Redact before crossing the native/plugin boundary or logging: no token-bearing URLs, query values, user information, authorization headers, raw exception interpolation, certificate blobs or trust-store contents. Use a host/port display and safe endpoint identifier; paths may also contain secrets. Native certificate details must be rendered as untrusted text. A safe correlation id may link diagnostics without including credentials.
Implementation surface
lib/src/plugins/plugin_transport_service.dart: connector trust policy, connection verification, scoped reset, cleanup and safe error mapping.lib/src/plugins/plugin_manager.dart: bridge validation, authenticated identity/generation checks, structured errors and both ordinary/device-connect transport entry points.- Host-owned trust persistence and existing native plugin/device settings: enrollment, manual entry, status and reset. Reuse existing storage/UI patterns; do not store approval in plugin-controlled data. If a database schema changes, include its migration test.
doc/Plugins.md: exact option/error contract and fetch boundary;doc/AI_TESTING_NOTES.md: platform support and verification limits. Add native settings tests and extendtest/plugins/plugin_manager_transport*_test.dartwith real TLS fixtures.- Add a platform adapter only where required by the pre-application-data verification boundary. No E64 protocol code is needed in the generic host change.
Acceptance and tests
Run deterministic state tests plus real loopback TLS/WSS tests with synthetic certificates and credentials. No grinder, private account or other physical device is required. Mocked connectors alone cannot establish platform support.
| Area | Required evidence |
|---|---|
| Compatibility | Default platform-trust success and rejection remain intact; plaintext transports reject custom-trust options; no effect on fetch permissions/behavior. Use a genuinely platform-trusted fixture/environment for the positive default case, not an accept-all injected connector. |
| Identity | Matching pin succeeds; changed self-signed and platform-trusted leaf certificates both fail; same key/new certificate fails; invalid dates and DNS/IP SAN mismatch fail; malformed pins fail before network activity. |
| Enrollment | First use, approval, decline, cancel, timeout, manual entry and app restart; peer changes between probe and reconnect; persistence failure never opens a connection or reports successful approval. |
| Credential boundary | Real test servers assert zero application bytes before approval and on mismatch. WSS explicitly asserts no upgrade request/query token; raw TLS asserts no login/command. Reject WSS redirects without contacting the target. Exercise a valid platform chain with the wrong pin so a bad-certificate-only implementation fails the test. |
| Isolation | Different plugins, hosts, ports, kinds and paths cannot reuse approval. Token/query changes preserve scope. Test URL normalization, forged identities, stale generations and permissions with zero DNS/connect attempts where validation should reject. |
| Reset and races | Reset during probe, approval, pending handshake, send and reconnect; stale completion/event cannot revive a transport. Same-scope concurrent opens cannot overwrite approval. Other scopes keep working. Test reset deletion failure and restart behavior. |
| Lifecycle | Unload, reload, uninstall and manager disposal release sockets, pending requests, prompts and listeners; durable approval survives only the intended lifecycle. Storage corruption/import cannot silently grant or recreate trust. |
| Errors and UI | Each failure above has the expected stable result and recovery action; no background prompts or trust-error retry loops. Widget tests cover approval/reset and redaction using secrets embedded in queries, paths and native exception messages. |
| Platform boundary | Run the real TLS and WSS cases on every platform claimed supported. Verify chain-valid and untrusted peers, reconnect/session reuse, and the pre-upgrade boundary through the production connector. Record unsupported combinations explicitly. |
Before implementation, confirm which supported platforms can expose and reject every peer certificate before application data. Dart's SecureSocket.connect and HttpClient.badCertificateCallback expose callbacks for otherwise unverifiable certificates; WebSocket.connect accepts a custom HTTP client. Those API hooks alone do not prove the required all-certificates WSS boundary or portable name validation. The current testing notes also distinguish injected TLS success from platform trust validation.
The remaining maintainer decisions are acceptance of this trust policy/API shape, the host storage/UI placement, and the platform adapter/support matrix. If a platform cannot meet the boundary, report custom trust as unsupported there until it can; do not weaken verification. E64 certificate stability, name coverage and renewal behavior need separate device evidence before claiming E64 compatibility.
Activity
tadelv commentedon Oct 14, 2026
@MaxRink — I think #979 has grown too large for the remaining 0.9.0 window.
For 0.9.0, could you propose the smallest useful slice that would let your external E64 Grinder plugin prove the transport contract on real hardware?
The goal for this slice is not to solve the complete generic certificate-management problem. It is to demonstrate that an independently distributed E64 plugin can:
Please suggest which parts of the current #979 proposal are actually necessary to demonstrate that end-to-end, and which parts can safely move to 0.9.1.
A real E64 + external-plugin smoke test should be the primary acceptance evidence for the 0.9.0 slice, backed by focused host-side regression tests for the security boundary.
I'd prefer a small generic primitive proven by your real consumer over landing the whole TOFU/storage/UI/platform matrix in one release.
MaxRink commentedon Oct 15, 2026
For 0.9.0, I propose a small, opt-in stored TLS identity for secure WebSockets, exercised by the external E64 plugin:
transport.openStoredIdentity(...)method fails closed on older hosts. It checks the certificate on the actual connection before the WebSocket upgrade, including when a replacement certificate would otherwise pass normal PKI validation. Hostname/date validation remains enforced, redirects are refused, and changed certificates require an explicit reset and new approval.The regression evidence should cover real loopback TLS/WSS, credentials withheld before approval, reconnect, changed certificates, plugin/server isolation, reset/unload races, the bound device transport API, and the native approval/reset UI. The real E64 smoke test is still the final acceptance step; mock tests will not be presented as hardware validation.
I would leave raw-TLS expansion, custom CAs/client certificates, certificate rotation workflows and the wider platform matrix to 0.9.1 or separate accepted issues. The first implementation must refuse unsupported platforms rather than claim untested support. Shot-bound grinder provenance remains the separate #974 work already moved to 0.9.1. This slice only establishes the generic secure transport and its concrete external consumer.