Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 21 additions & 3 deletions src/packages/common/src/auth/server-pins.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 };
Expand Down Expand Up @@ -395,7 +408,7 @@ async function parseProof(proof: string): Promise<ParsedProof | ServerProofFailu
}

let header: { alg?: string; kid?: string; jwk?: JsonWebKey };
let payload: { nonce?: string; iss?: string; exp?: number; host?: string };
let payload: { nonce?: string; iss?: string; exp?: number; iat?: number; host?: string };
try {
header = JSON.parse(new TextDecoder().decode(base64UrlToBytes(parts[0])));
payload = JSON.parse(new TextDecoder().decode(base64UrlToBytes(parts[1])));
Expand Down Expand Up @@ -433,7 +446,12 @@ async function parseProof(proof: string): Promise<ParsedProof | ServerProofFailu
}

if (typeof payload.exp === "number" && payload.exp * 1000 < Date.now()) {
return { reason: "expired", detail: "Proof has expired" };
return {
reason: "expired",
detail: "Proof has expired",
skewMs:
typeof payload.iat === "number" ? Date.now() - payload.iat * 1000 : undefined,
};
}

return {
Expand Down
12 changes: 12 additions & 0 deletions src/packages/socket/src/components/ServerLoadingStates.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ interface ServerLoadingStatesProps {
serverFailure?: { error: string; message?: string };
hasTimedOut: boolean;
refusalReason?: string;
refusalHelpUrl?: string;
connectionStatus?: 'connected' | 'disconnected' | 'connecting' | 'reconnecting' | 'refused';
onReconnect?: () => void;
}
Expand Down Expand Up @@ -52,6 +53,7 @@ export const ServerLoadingStates = ({
hasTimedOut,
connectionStatus,
refusalReason,
refusalHelpUrl,
onReconnect,
}: ServerLoadingStatesProps) => {
if (serverFailure) {
Expand Down Expand Up @@ -134,6 +136,16 @@ export const ServerLoadingStates = ({
{refusalReason ??
"This server could not prove it is the one you joined before."}
</span>
{refusalHelpUrl && (
<a
href={refusalHelpUrl}
target="_blank"
rel="noreferrer"
className="text-sm underline text-gryt-muted hover:text-gryt-text"
>
How to fix this
</a>
)}
</div>
</div>
</div>
Expand Down
3 changes: 2 additions & 1 deletion src/packages/socket/src/components/serverView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 });
Expand Down Expand Up @@ -250,6 +250,7 @@ export const ServerView = () => {
serverFailure={serverFailure} hasTimedOut={hasTimedOut}
connectionStatus={currentConnectionStatus}
refusalReason={currentRefusalReason}
refusalHelpUrl={currentRefusalHelpUrl}
onReconnect={() => reconnectServer(currentlyViewingServer.host)}
/>
);
Expand Down
3 changes: 3 additions & 0 deletions src/packages/socket/src/hooks/useServerState.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ type UseServerStateResult = {
hasTimedOut: boolean;
currentConnectionStatus: ConnectionStatus;
currentRefusalReason?: string;
currentRefusalHelpUrl?: string;
reconnectServer: (host: string) => void;
};

Expand Down Expand Up @@ -86,6 +87,7 @@ export function useServerState(): UseServerStateResult {
failedServerDetails,
serverConnectionStatus,
refusalReason,
refusalHelpUrl,
reconnectServer,
requestMemberList,
tokenRevision,
Expand Down Expand Up @@ -519,6 +521,7 @@ export function useServerState(): UseServerStateResult {
hasTimedOut,
currentConnectionStatus,
currentRefusalReason: currentlyViewingServer ? refusalReason?.[currentlyViewingServer.host] : undefined,
currentRefusalHelpUrl: currentlyViewingServer ? refusalHelpUrl?.[currentlyViewingServer.host] : undefined,
reconnectServer,
};
}
10 changes: 8 additions & 2 deletions src/packages/socket/src/hooks/useSockets.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -68,6 +68,9 @@ function useSocketsHook() {
const [serverConnectionStatus, setServerConnectionStatus] = useState<Record<string, 'connected' | 'disconnected' | 'connecting' | 'reconnecting' | 'refused'>>({});
// Why a server was refused, so the UI can say it rather than guessing.
const [refusalReason, setRefusalReason] = useState<Record<string, string>>({});
// 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<Record<string, string>>({});
const wasEverConnectedRef = useRef<Record<string, boolean>>({});
const serverDetailsListRef = useRef(serverDetailsList);

Expand Down Expand Up @@ -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 });
});

Expand Down Expand Up @@ -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(
Expand All @@ -519,6 +524,7 @@ export const useSockets = singletonHook(
failedServerDetails: {},
serverConnectionStatus: {},
refusalReason: {},
refusalHelpUrl: {},
reconnectServer: () => {},
leaveServer: () => {},
tokenRevision: 0,
Expand Down
45 changes: 43 additions & 2 deletions src/packages/socket/src/utils/serverAuth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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" },
Expand Down Expand Up @@ -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.";
}
Expand Down
Loading