fix: show notification banner on TOFU first-use cert trust (BUG-133)

The cert-tofu event listener now handles "trusted_first_use" status
and shows a visible notification banner with the server hostname and
SHA-256 fingerprint. Adds onCertFirstTrust callback to the WS client
API. First-use certificate trust is no longer silent.
This commit is contained in:
J3vb
2026-04-02 12:47:07 +02:00
parent 6dbae5674e
commit 3bf7d4cd51
6 changed files with 57 additions and 1 deletions
+19 -1
View File
@@ -59,6 +59,7 @@ export function parseStoredFingerprint(message?: string): string | undefined {
}
export type CertMismatchListener = (event: CertTofuEvent) => void;
export type CertFirstTrustListener = (event: CertTofuEvent) => void;
export interface WsClientConfig {
readonly host: string;
@@ -103,6 +104,9 @@ export function createWsClient() {
// TOFU cert mismatch listeners
const certMismatchListeners = new Set<CertMismatchListener>();
// TOFU first-trust listeners (BUG-133)
const certFirstTrustListeners = new Set<CertFirstTrustListener>();
function setState(newState: ConnectionState): void {
if (state !== newState) {
state = newState;
@@ -325,7 +329,15 @@ export function createWsClient() {
const raw = e.payload as CertTofuEvent;
log.info("TOFU cert event", { host: raw.host, status: raw.status });
if (raw.status === "mismatch") {
if (raw.status === "trusted_first_use") {
log.warn("TOFU: first-use certificate trust", {
host: raw.host,
fingerprint: raw.fingerprint,
});
for (const listener of certFirstTrustListeners) {
listener(raw);
}
} else if (raw.status === "mismatch") {
const evt: CertTofuEvent = {
...raw,
storedFingerprint: parseStoredFingerprint(raw.message),
@@ -473,6 +485,12 @@ export function createWsClient() {
return () => stateListeners.delete(listener);
},
/** Register a listener for TOFU first-trust events (BUG-133). */
onCertFirstTrust(listener: CertFirstTrustListener): () => void {
certFirstTrustListeners.add(listener);
return () => certFirstTrustListeners.delete(listener);
},
/** Register a listener for TOFU certificate mismatch events. */
onCertMismatch(listener: CertMismatchListener): () => void {
certMismatchListeners.add(listener);
+28
View File
@@ -96,6 +96,34 @@ let connectedOverlay: ConnectedOverlayControl | null = null;
let lastConnectHost = "";
let lastConnectToken = "";
// Certificate first-trust notification (BUG-133).
// Show a brief banner so the user is aware a new server cert was pinned.
ws.onCertFirstTrust((evt: CertTofuEvent) => {
log.warn("TOFU: first-use certificate pinned", {
host: evt.host,
fingerprint: evt.fingerprint,
});
const banner = document.createElement("div");
Object.assign(banner.style, {
position: "fixed",
top: "12px",
left: "50%",
transform: "translateX(-50%)",
background: "#2d5a27",
color: "#e0e0e0",
padding: "10px 20px",
borderRadius: "8px",
fontSize: "13px",
zIndex: "10000",
boxShadow: "0 4px 12px rgba(0,0,0,0.5)",
cursor: "default",
});
banner.textContent = `New server certificate trusted for ${evt.host}`;
banner.title = `SHA-256: ${evt.fingerprint}`;
document.body.appendChild(banner);
setTimeout(() => banner.remove(), 8000);
});
// Certificate mismatch modal handler
let certModalActive = false;
ws.onCertMismatch((evt: CertTofuEvent) => {
@@ -72,6 +72,10 @@ export function createMockWsClient() {
return () => stateListeners.delete(listener);
},
onCertFirstTrust(): () => void {
return () => {};
},
onCertMismatch(_listener: CertMismatchListener): () => void {
return () => {};
},
@@ -63,6 +63,10 @@ function createMockWsClient(): MockWsClient {
return () => stateListeners.delete(listener);
},
onCertFirstTrust(): () => void {
return () => {};
},
onCertMismatch(): () => void {
return () => {};
},
@@ -51,6 +51,7 @@ function createMockWs() {
};
},
onStateChange: vi.fn(() => () => {}),
onCertFirstTrust: vi.fn(() => () => {}),
onCertMismatch: vi.fn(() => () => {}),
acceptCertFingerprint: vi.fn(async () => {}),
getState: vi.fn(() => "disconnected" as const),
@@ -31,6 +31,7 @@ function createMockWs(state: "connected" | "disconnected" = "connected"): WsClie
stateListeners.add(listener);
return () => stateListeners.delete(listener);
}),
onCertFirstTrust: vi.fn().mockReturnValue(() => {}),
onCertMismatch: vi.fn().mockReturnValue(() => {}),
acceptCertFingerprint: vi.fn(),
getState: vi.fn(() => currentState),