diff --git a/src/packages/common/src/auth/server-pins.ts b/src/packages/common/src/auth/server-pins.ts index c2ad610..716ecad 100644 --- a/src/packages/common/src/auth/server-pins.ts +++ b/src/packages/common/src/auth/server-pins.ts @@ -61,7 +61,20 @@ export type ServerProofFailure = | { reason: "malformed"; detail: string } | { reason: "bad_signature"; detail: string } | { reason: "nonce_mismatch"; detail: string } - | { reason: "expired"; detail: string } + | { + reason: "expired"; + detail: string; + /** + * How far the server's clock sits behind ours, in milliseconds, negative + * if it is ahead. Absent when the proof carried no `iat` to compare. + * + * Measured from `iat` rather than `exp` because it is the one instant both + * sides describe: the server says when it signed, and we know when we read + * it. The gap is the skew plus the network round trip, and a round trip is + * milliseconds against a window of a minute, so what survives is the skew. + */ + skewMs?: number; + } | { reason: "key_mismatch"; detail: string; expectedKeyId: string; presentedKeyId: string } | { reason: "proof_withdrawn"; detail: string; expectedKeyId: string } | { reason: "blocked"; detail: string; keyId: string }; @@ -395,7 +408,7 @@ async function parseProof(proof: string): Promise void; } @@ -52,6 +53,7 @@ export const ServerLoadingStates = ({ hasTimedOut, connectionStatus, refusalReason, + refusalHelpUrl, onReconnect, }: ServerLoadingStatesProps) => { if (serverFailure) { @@ -134,6 +136,16 @@ export const ServerLoadingStates = ({ {refusalReason ?? "This server could not prove it is the one you joined before."} + {refusalHelpUrl && ( + + How to fix this + + )} diff --git a/src/packages/socket/src/components/serverView.tsx b/src/packages/socket/src/components/serverView.tsx index 3ddcdbc..310cac3 100644 --- a/src/packages/socket/src/components/serverView.tsx +++ b/src/packages/socket/src/components/serverView.tsx @@ -68,7 +68,7 @@ export const ServerView = () => { selectedChannelId, setSelectedChannelId, handleVoiceDisconnect, setPendingChannelId, currentChannelId, currentConnection, accessToken, activeConversationId, serverFailure, hasTimedOut, - currentConnectionStatus, currentRefusalReason, reconnectServer, + currentConnectionStatus, currentRefusalReason, currentRefusalHelpUrl, reconnectServer, } = useServerState(); const sidebarEditor = useSidebarEditor({ currentlyViewingServer, currentConnection, accessToken, serverDetailsList }); @@ -250,6 +250,7 @@ export const ServerView = () => { serverFailure={serverFailure} hasTimedOut={hasTimedOut} connectionStatus={currentConnectionStatus} refusalReason={currentRefusalReason} + refusalHelpUrl={currentRefusalHelpUrl} onReconnect={() => reconnectServer(currentlyViewingServer.host)} /> ); diff --git a/src/packages/socket/src/hooks/useServerState.ts b/src/packages/socket/src/hooks/useServerState.ts index e4b2f07..03ecba1 100644 --- a/src/packages/socket/src/hooks/useServerState.ts +++ b/src/packages/socket/src/hooks/useServerState.ts @@ -58,6 +58,7 @@ type UseServerStateResult = { hasTimedOut: boolean; currentConnectionStatus: ConnectionStatus; currentRefusalReason?: string; + currentRefusalHelpUrl?: string; reconnectServer: (host: string) => void; }; @@ -86,6 +87,7 @@ export function useServerState(): UseServerStateResult { failedServerDetails, serverConnectionStatus, refusalReason, + refusalHelpUrl, reconnectServer, requestMemberList, tokenRevision, @@ -519,6 +521,7 @@ export function useServerState(): UseServerStateResult { hasTimedOut, currentConnectionStatus, currentRefusalReason: currentlyViewingServer ? refusalReason?.[currentlyViewingServer.host] : undefined, + currentRefusalHelpUrl: currentlyViewingServer ? refusalHelpUrl?.[currentlyViewingServer.host] : undefined, reconnectServer, }; } diff --git a/src/packages/socket/src/hooks/useSockets.ts b/src/packages/socket/src/hooks/useSockets.ts index 427f899..e37efa8 100644 --- a/src/packages/socket/src/hooks/useSockets.ts +++ b/src/packages/socket/src/hooks/useSockets.ts @@ -18,7 +18,7 @@ import { import { MemberInfo } from "../components/MemberSidebar"; import { Clients } from "../types/clients"; -import { guardSocket, serverProofErrorMessage } from "../utils/serverAuth"; +import { guardSocket, serverProofErrorMessage, serverProofHelpUrl } from "../utils/serverAuth"; import { syncAvatarToHost } from "../utils/syncAvatarToHost"; import { useSocketEvents } from "./useSocketEvents"; @@ -68,6 +68,9 @@ function useSocketsHook() { const [serverConnectionStatus, setServerConnectionStatus] = useState>({}); // Why a server was refused, so the UI can say it rather than guessing. const [refusalReason, setRefusalReason] = useState>({}); + // Kept beside the sentence rather than baked into it, so the card can render + // a real link and the toast can stay plain text. + const [refusalHelpUrl, setRefusalHelpUrl] = useState>({}); const wasEverConnectedRef = useRef>({}); const serverDetailsListRef = useRef(serverDetailsList); @@ -221,6 +224,8 @@ function useSocketsHook() { // refused it on purpose sends them off debugging the wrong thing. setServerConnectionStatus(prev => ({ ...prev, [host]: 'refused' })); setRefusalReason(prev => ({ ...prev, [host]: serverProofErrorMessage(decision) })); + const helpUrl = serverProofHelpUrl(decision); + if (helpUrl) setRefusalHelpUrl(prev => ({ ...prev, [host]: helpUrl })); toast.error(serverProofErrorMessage(decision), { id: toastId, duration: 12000 }); }); @@ -503,7 +508,7 @@ function useSocketsHook() { } }; - return { sockets, serverDetailsList, clients, memberLists, serverProfiles, setServerProfiles, getChannelDetails, requestMemberList, failedServerDetails, serverConnectionStatus, refusalReason, reconnectServer, leaveServer, tokenRevision }; + return { sockets, serverDetailsList, clients, memberLists, serverProfiles, setServerProfiles, getChannelDetails, requestMemberList, failedServerDetails, serverConnectionStatus, refusalReason, refusalHelpUrl, reconnectServer, leaveServer, tokenRevision }; } export const useSockets = singletonHook( @@ -519,6 +524,7 @@ export const useSockets = singletonHook( failedServerDetails: {}, serverConnectionStatus: {}, refusalReason: {}, + refusalHelpUrl: {}, reconnectServer: () => {}, leaveServer: () => {}, tokenRevision: 0, diff --git a/src/packages/socket/src/utils/serverAuth.ts b/src/packages/socket/src/utils/serverAuth.ts index a929501..9a6252a 100644 --- a/src/packages/socket/src/utils/serverAuth.ts +++ b/src/packages/socket/src/utils/serverAuth.ts @@ -147,6 +147,34 @@ function logDecision(host: string, decision: ServerProofDecision): void { } } +/** + * A rough gap in words, because the exact figure is not the point. + * + * Somebody reading this needs to know whether a clock is a minute out or a day + * out — the first is a missing time sync, the second is usually a machine that + * came up without a battery-backed clock at all. Rounding to something sayable + * makes that difference obvious and a stray hundred milliseconds invisible. + */ +function describeGap(ms: number): string { + const seconds = Math.round(ms / 1000); + if (seconds < 90) return `${seconds} second${seconds === 1 ? "" : "s"}`; + const minutes = Math.round(seconds / 60); + if (minutes < 90) return `${minutes} minute${minutes === 1 ? "" : "s"}`; + const hours = Math.round(minutes / 60); + if (hours < 48) return `${hours} hour${hours === 1 ? "" : "s"}`; + const days = Math.round(hours / 24); + return `${days} day${days === 1 ? "" : "s"}`; +} + +/** Where to send somebody who cannot act on the sentence alone. */ +export function serverProofHelpUrl( + decision: ServerProofDecision & { action: "block" }, +): string | null { + return decision.failure.reason === "expired" + ? "https://docs.gryt.chat/docs/guide/troubleshooting#server-clock-is-wrong" + : null; +} + /** What to show a user when a server is refused. */ export function serverProofErrorMessage( decision: ServerProofDecision & { action: "block" }, @@ -189,8 +217,21 @@ export function serverProofErrorMessage( } case "nonce_mismatch": return "This server's identity proof answered a different request. Try again."; - case "expired": - return "This server's identity proof had expired. Check the clock on both machines."; + case "expired": { + // "Check the clock on both machines" asked the reader to inspect + // something they may not control, and made them work out which of the two + // was wrong. The client already knows: it compared the server's timestamp + // against its own to decide the proof had expired at all. + const skew = failure.skewMs; + if (skew === undefined) { + return "This server's identity proof had expired, which usually means its clock is wrong."; + } + const direction = skew > 0 ? "behind" : "ahead of"; + return ( + `This server's clock is about ${describeGap(Math.abs(skew))} ${direction} ` + + "yours, so its identity proof looked expired. If it is your server, turn on time sync." + ); + } default: return "This server's identity proof could not be checked."; }