import { ReactNode, useContext, useEffect, useMemo, useRef, useState } from "react"; import { useQuery } from "@tanstack/react-query"; import { Context as ConnectKitContext } from "@multiplatform.one/connectkit"; import { GpgContext, GpgConfig, GpgStatusState } from "./GpgContext"; import { assertGpgSignature, gpgSignCommand } from "./signer"; type Props = GpgConfig & { children: ReactNode; onSignIn?: () => void; }; export const GPG_NONCE_QUERY_KEY = "ckGpgNonce"; // UTF-8-safe base64 — the server decodes with new String(decoder.decode(...), UTF_8), // so plain btoa would corrupt non-ASCII. Must keep the unescape(encodeURIComponent()). const b64 = (s: string) => window.btoa(unescape(encodeURIComponent(s))); export const GpgProvider = ({ children, enabled = true, nonceRefetchInterval = false, buildMessage, onSignIn, ...gpgConfig }: Props) => { const [status, setStatus] = useState(GpgStatusState.READY); const resetStatus = () => setStatus(GpgStatusState.READY); // Mirror SIWEProvider's guards: single instance, and mounted OUTSIDE ConnectKitProvider // so the modal's GPG screen can read this context. if (useContext(GpgContext)) { throw new Error("Multiple, nested usages of GpgProvider detected. Please use only one."); } if (useContext(ConnectKitContext)) { throw new Error("ConnectKitProvider must be mounted inside GpgProvider."); } const nonce = useQuery({ queryKey: [GPG_NONCE_QUERY_KEY], queryFn: () => gpgConfig.getNonce(), refetchInterval: nonceRefetchInterval, // The challenge the user signs MUST equal the one we submit. Never refetch the nonce // out from under an in-flight signature — especially on window-focus, which fires every // time the user tabs to their terminal to run gpg and back. refetchOnWindowFocus: false, refetchOnReconnect: false, staleTime: Infinity, }); const { subscribeNonceReset } = gpgConfig; const refetchNonce = nonce.refetch; useEffect(() => { if (!subscribeNonceReset) return; return subscribeNonceReset(() => { void refetchNonce(); }); }, [subscribeNonceReset, refetchNonce]); // Compute the challenge ONCE per nonce (it embeds an Issued At timestamp); recomputing // on every render would desync the displayed/copied message from the one base64'd into // `password`, breaking the signature (which is verified over the exact message bytes). // buildMessage is read through a ref so an inline-closure prop (new identity every // render) can't retrigger the memo and desync the message the same way. const buildMessageRef = useRef(buildMessage); buildMessageRef.current = buildMessage; const message = useMemo( () => (nonce.data ? buildMessageRef.current({ nonce: nonce.data }) : null), [nonce.data], ); const command = useMemo(() => (message ? gpgSignCommand(message) : null), [message]); const signIn = async (pastedBlob: string): Promise => { let blob: string; try { blob = assertGpgSignature(pastedBlob); } catch (error) { setStatus(GpgStatusState.ERROR); throw error; } if (!message) { setStatus(GpgStatusState.ERROR); throw new Error("Could not fetch nonce"); } try { setStatus(GpgStatusState.LOADING); // username = base64(signature + public key), password = base64(message). const result = await gpgConfig.onSign({ username: b64(blob), password: b64(message), message, }); // A keycloak browser login navigates away here; an API consumer returns a boolean. if (result === false) throw new Error("GPG sign-in was rejected"); setStatus(GpgStatusState.SUCCESS); onSignIn?.(); return true; } catch (error) { setStatus(GpgStatusState.ERROR); throw error; } }; return ( {children} ); };