import { useSyncExternalStore } from "react"; import { gpgSignatureCovers } from "./signature"; export { gpgSignatureCovers } from "./signature"; // The GPG copy/paste signer as a client, shared by login (GpgProvider) and any // other signing an app needs. There is no wallet to talk to: signMessage parks a // request, the app shows the terminal command, the user pastes the output back // and submit() resolves the pending promise. Only react and @noble/hashes are // imported, so the `@multiplatform.one/gpg/signer` subpath stays out of the // connectkit graph. export const GPG_SIGNATURE_BEGIN = "-----BEGIN PGP SIGNATURE-----"; export const GPG_PUBLIC_KEY_BEGIN = "-----BEGIN PGP PUBLIC KEY BLOCK-----"; /** * The terminal command that signs `message`: a detached armored signature plus * the exported public key, which is the blob the user pastes back. printf %s adds * no newline, so the signed bytes equal `message`. Single quotes are POSIX-escaped * so a message cannot break out of the quoting. */ export function gpgSignCommand(message: string): string { return `{ printf %s '${message.replace(/'/g, "'\\''")}' | gpg --detach-sign --armor; gpg --armor --export; }`; } /** Trim the pasted terminal output and reject it unless it holds both armored blocks. */ export function assertGpgSignature(blob: string): string { const trimmed = blob.trim(); if (!trimmed.includes(GPG_SIGNATURE_BEGIN) || !trimmed.includes(GPG_PUBLIC_KEY_BEGIN)) { throw new Error( "Paste the FULL output — it must contain both a PGP SIGNATURE and a PUBLIC KEY block.", ); } return trimmed; } export interface GpgSignRequest { message: string; command: string; } export interface GpgSigner { readonly family: "gpg"; /** A GPG identity is the key fingerprint, which only arrives with the signature. */ readonly address: null; readonly capabilities: readonly "signMessage"[]; /** The request waiting for pasted output, or null. */ readonly pending: GpgSignRequest | null; /** * Ask the user to sign `message` in their terminal. Resolves with the pasted * armored signature followed by the armored public key, the same blob Keycloak's * GPG verifier takes. A second call cancels the first. */ signMessage(message: string): Promise; /** * Hand pasted output to `request`, the pending request the paste box showed. * Throws, and keeps the request pending, when `request` is no longer the * pending one, when the paste is incomplete, or when its signature was made * over a different message. */ submit(blob: string, request: GpgSignRequest): void; cancel(reason?: Error): void; subscribe(listener: () => void): () => void; } export function createGpgSigner(): GpgSigner { let pending: GpgSignRequest | null = null; let settle: { resolve: (blob: string) => void; reject: (error: Error) => void } | null = null; const listeners = new Set<() => void>(); const emit = () => { for (const listener of listeners) listener(); }; const signer: GpgSigner = { family: "gpg", address: null, capabilities: ["signMessage"], get pending() { return pending; }, signMessage(message) { settle?.reject(new Error("GPG signing was superseded by a newer request")); return new Promise((resolve, reject) => { settle = { resolve, reject }; pending = { message, command: gpgSignCommand(message) }; emit(); }); }, submit(blob, request) { if (!settle || !pending) throw new Error("No GPG signature was requested"); if (request !== pending) { throw new Error( "That GPG request was superseded; run the current command and paste its output", ); } const signature = assertGpgSignature(blob); if (!gpgSignatureCovers(signature, pending.message)) { throw new Error( "That signature is over a different message; run the current command and paste its output", ); } const { resolve } = settle; settle = null; pending = null; emit(); resolve(signature); }, cancel(reason) { if (!settle) return; const { reject } = settle; settle = null; pending = null; emit(); reject(reason ?? new Error("GPG signing was cancelled")); }, subscribe(listener) { listeners.add(listener); return () => listeners.delete(listener); }, }; return signer; } /** * Subscribe a component to a GPG signer so it re-renders when a request is * parked or settled. Omitted, it is the app-wide signer, so the component that * asks for a signature and the one that shows the paste box share one request. */ export function useGpgSigner(signer: GpgSigner = defaultSigner()): GpgSigner { useSyncExternalStore( signer.subscribe, () => signer.pending, () => signer.pending, ); return signer; } let sharedSigner: GpgSigner | null = null; function defaultSigner(): GpgSigner { sharedSigner ??= createGpgSigner(); return sharedSigner; }