diff options
Diffstat (limited to 'src/routes')
-rw-r--r-- | src/routes/canaries/+page.svelte | 105 | ||||
-rw-r--r-- | src/routes/canaries/Canary.svelte | 92 | ||||
-rw-r--r-- | src/routes/canaries/canaries.ts | 54 | ||||
-rw-r--r-- | src/routes/canaries/fallback-keys.ts | 34 | ||||
-rw-r--r-- | src/routes/canaries/keystore.ts | 71 | ||||
-rw-r--r-- | src/routes/canaries/napatha:kyun.host/+server.ts | 6 |
6 files changed, 337 insertions, 25 deletions
diff --git a/src/routes/canaries/+page.svelte b/src/routes/canaries/+page.svelte index adbbdcc..24e968a 100644 --- a/src/routes/canaries/+page.svelte +++ b/src/routes/canaries/+page.svelte @@ -1,5 +1,106 @@ +<svelte:options runes /> + <script lang="ts"> - import keyStore from "./keystore"; + import { browser } from "$app/environment"; + import { canaries } from "./canaries"; + import Canary from "./Canary.svelte"; + import { initKeystore } from "./keystore"; </script> -{@debug keyStore} +<svelte:head> + <title>Warrant Canaries - mem.estrogen.zone</title> +</svelte:head> + +<div class="flex min-h-full min-w-full flex-col justify-between"> + <div> + {#await initKeystore} + {#if browser} + Preparing... + {:else} + Awaiting Browser + <noscript> + If you're using noscript, here are the raw canaries: + {#each canaries as canary} + <a href={canary.url}>{canary.signer}'s canary for {canary.name}</a> + {/each} + </noscript> + {/if} + {:then _} + <div class="canaries flex gap-4 flex-wrap max-w-full"> + {#each canaries as canary} + <Canary {canary} /> + {/each} + </div> + {:catch e} + <div class="p-4 bg-red-700 text-white max-w-full overflow-x-auto"> + Encoutnered Error: + <pre>{JSON.stringify( + { + raw: e, + fileName: "fileName" in e ? e.fileName : undefined, + name: "name" in e ? e.name : undefined, + message: "message" in e ? e.message : undefined, + stack: "stack" in e ? e.stack : undefined, + }, + null, + 2, + )}</pre> + </div> + {/await} + </div> + + <div + class="mt-3 sticky bottom-0 block bg-black bg-opacity-30 p-4 rounded-xl backdrop-blur-xl" + > + <p class="text-xs text-white"> + <span class="about opacity-60 hover:opacity-80 transition-all"> + This page contains various <a + href="https://en.wikipedia.org/wiki/Warrant_canary" + target="_blank" + rel="noopener noreferrer" + class="opacity-90 hover:underline text-blue-400 hover:text-blue-300" + >liberty canaries</a + > + that I, or my friends, depend upon or are otherwise interested in. + </span> + <span + class="validation-attempt opacity-60 hover:opacity-80 transition-all" + class:hidden={!browser} + > + <br /> + It makes an attempt to validate their signatures, however it does not attempt + to check if they've been updated in time for their next scheduled update. + It is your responsibility to ensure that the canary's contents match your + requirements. + </span> + <span + class="trust-server opacity-60 hover:opacity-80 transition-all" + class:hidden={!browser} + > + <br /> + The server at + <code class="font-grub px-1 bg-white bg-opacity-5 rounded-md" + >{#if typeof location !== "undefined"}{location.host ?? + location.hostname}{:else}mem.estrogen.zone{/if}</code + > + serves the keys and the canaries. Make sure you trust it to serve correct + keys and canaries, or validate the signatures of the + <code class="font-grub px-1 bg-white bg-opacity-5 rounded-md" + >Signed</code + > links with keys obtained externally. + </span> + <span + class="legend opacity-60 hover:opacity-80 transition-all" + class:hidden={!browser} + ><br />If a signature check passes, the background of the respective + canary turns + <span class="bg-white text-black">white</span>. Whilst fetching, it's + background turns <span class="bg-blue-500 text-white">blue</span>. If a + signature check fails, it will turn + <span class="bg-red-500 text-white">red</span>. If the canary cannot be + found, it turns a different shade of + <span class="bg-red-800 text-white">red</span>.</span + > + </p> + </div> +</div> diff --git a/src/routes/canaries/Canary.svelte b/src/routes/canaries/Canary.svelte new file mode 100644 index 0000000..284ed9f --- /dev/null +++ b/src/routes/canaries/Canary.svelte @@ -0,0 +1,92 @@ +<script lang="ts"> + import type { Canary } from "./canaries"; + let { + canary, + }: { + canary: Canary; + } = $props(); + let rawText = $state(null as string | null); + let failedFetch = $state(false); + let url = $state( + null as { + stripped: string; + signed: string; + } | null, + ); + let status = $derived( + url !== null + ? ("ok" as const) + : failedFetch + ? ("failed_fetch" as const) + : rawText === null + ? ("fetching" as const) + : ("failed_sigcheck" as const), + ); + $effect(() => { + (async (canary) => { + rawText = await canary.getRawText(); + })(canary); + }); + $effect(() => { + (async (canary) => { + url = await canary.getUrl(); + })(canary); + }); +</script> + +<div + class="canary p-4 bg-opacity-10 flex-1 max-w-[1024px] min-w-[min(100%,500px)] overflow-x-auto flex flex-col justify-between rounded-2xl" + class:bg-white={status === "ok"} + class:bg-blue-500={status === "fetching"} + class:bg-red-500={status === "failed_sigcheck"} + class:bg-red-800={status === "failed_fetch"} +> + <div class="top"> + <small class="opacity-50">{canary.signer}'s canary for</small> + <h2 class="canary-name text-lg">{canary.name}</h2> + <p class="opacity-80 text-white font-normal mt-1"> + {#if status === "ok"} + {canary.description} + {:else if status === "fetching"} + Fetching Canary... + {:else if status === "failed_sigcheck"} + Failed Signature Check!<br /> + Either the upstream key changed, or the canary is fucked! + {:else if status === "failed_fetch"} + Failed to fetch canary! + {/if} + </p> + </div> + <div class="bottom"> + <div class="mt-2"> + {#if url} + <span class="opacity-70">Get Canary:</span> + <a + class="opacity-90 hover:underline text-blue-400 hover:text-blue-300" + href={url.signed} + > + Signed + </a> + <a + class="opacity-90 hover:underline text-blue-400 hover:text-blue-300" + href={url.stripped} + > + Stripped + </a> + {:else if status === "failed_fetch"} + <span class="opacity-70">Get Canary:</span> + <a + class="opacity-90 hover:underline text-blue-400 hover:text-blue-300" + href={canary.url} + > + Signed + </a> + <span class="opacity-70 text-blue-400 grayscale cursor-not-allowed"> + Stripped + </span> + {:else} + No URL yet + {/if} + </div> + </div> +</div> diff --git a/src/routes/canaries/canaries.ts b/src/routes/canaries/canaries.ts index de5c44f..d28baab 100644 --- a/src/routes/canaries/canaries.ts +++ b/src/routes/canaries/canaries.ts @@ -1,25 +1,69 @@ +import { browser } from "$app/environment"; +import { validateSignature } from "./keystore"; + export const canaries: Canary[] = []; export class Canary { public constructor( public name: string, public description: string, + public signer: string, public url: string, public keyIdentifier: string, + public contentType: string = 'text/plain', ) { canaries.push(this); } + public forceContentType = false; + public async getRawText() { + if (!browser) throw new Error('This should only be done in a browser.') + const res = await fetch(this.url) + if (res.ok) { + if (!this.forceContentType) this.contentType = res.headers.get('Content-Type') || this.contentType + const text = await res.text() + return text + } else { + throw new Error(`Fetching canary failed with code ${res.status} (${res.statusText}): +${await res.text().catch(e => `Unknown (Unable to get text, ${JSON.stringify(e)})`)}`) + } + } + public async getValidatedText(rawText: string | Promise<string> = this.getRawText()) { + return await validateSignature(await rawText, this.keyIdentifier) + } + /** Returns downloadable data url if signature passes, otherwise returns null */ + public async getUrl(rawText: string | Promise<string> = this.getRawText()) { + const t = await rawText; + let stripped: string; + try { + stripped = await this.getValidatedText(t); + } catch (error) { + console.warn('Failed to validate signature: ', error); + return null; + } + return { + stripped: URL.createObjectURL(new Blob([stripped], { + type: this.contentType, + })), + signed: URL.createObjectURL(new Blob([t], { + type: this.contentType, + })), + } + } } new Canary( - 'estrogen.zone, memdmp', - 'This canary is responsible for services hosted around estrogen.zone and yuridick.gay', + 'estrogen.zone', + 'Services hosted around estrogen.zone and yuridick.gay', + 'memdmp', '/canaries/memdmp:estrogen.zone', 'memdmp', + 'text/plain; charset=utf-8' ); new Canary( 'kyun.host', - 'This canary is responsible for the VPS provider "kyun.host"', - // '/canaries/napatha:kyun.host', - 'https://files.kyun.host/canary.txt', + 'The VPS provider "kyun.host"', + 'napatha', + '/canaries/napatha:kyun.host', + // 'https://files.kyun.host/canary.txt', 'napatha', + 'text/plain; charset=utf-8' ); diff --git a/src/routes/canaries/fallback-keys.ts b/src/routes/canaries/fallback-keys.ts new file mode 100644 index 0000000..fa3d7a7 --- /dev/null +++ b/src/routes/canaries/fallback-keys.ts @@ -0,0 +1,34 @@ +export const fallbackKeys = new Map<string, string>().set('B546778F06BBCC8EC167DB3CD919706487B8B6DE', `-----BEGIN PGP PUBLIC KEY BLOCK----- +Comment: User ID: memdmp <memdmp@estrogen.zone> +Comment: a.k.a.: memdmp <memdmp@memeware.net> +Comment: Valid from: 11 Sep 2024 08:29:03 +Comment: Type: 255-bit EdDSA (secret key available) +Comment: Usage: Signing, Encryption, Certifying User IDs +Comment: Fingerprint: B546778F06BBCC8EC167DB3CD919706487B8B6DE + +mDMEZuE4rxYJKwYBBAHaRw8BAQdAGFCSBdoIrbk4DcSu8YIF+X+V9v4SFSCt1wl5 +ayp3anG0HW1lbWRtcCA8bWVtZG1wQGVzdHJvZ2VuLnpvbmU+iJMEExYKADsWIQS1 +RnePBrvMjsFn2zzZGXBkh7i23gUCZwf3DQIbAwULCQgHAgIiAgYVCgkICwIEFgID +AQIeBwIXgAAKCRDZGXBkh7i23uHRAP9IQylDWCAypelUhtcBFCb+cFT4NDOX/1N5 +kqCsp0a7TQEAlXR3vkGsPkSymYpFm26837L9IJeNP4wvcBldKdbchgiIdQQQFgoA +HRYhBIkjiMT2LOvXZkCzo8SN5mcaHws1BQJnITEzAAoJEMSN5mcaHws11+oA/jSr +1C24UtDdeyLMpl8d6l3ab9nUAnErlRNWtwux//YEAQDqAHuXEOC/gZh9m+hwHF2W +bugrgEXqTktuK4sCZbsOCLQcbWVtZG1wIDxtZW1kbXBAbWVtZXdhcmUubmV0PoiT +BBMWCgA7AhsDBQsJCAcCAiICBhUKCQgLAgQWAgMBAh4HAheAFiEEtUZ3jwa7zI7B +Z9s82RlwZIe4tt4FAmbhOnYACgkQ2RlwZIe4tt6c0QEAwS5WoDMmLpbC4VIIBho5 +1E9avJqOSbUjBz+RdJ+23cYA/0/nWStYLQWMiuguoFD3WWpl38qt1iavtpboEdAY +jDcNiHUEEBYKAB0WIQSJI4jE9izr12ZAs6PEjeZnGh8LNQUCZyExMwAKCRDEjeZn +Gh8LNekGAQDErOsA2I522vx1m9a0lgS1QPntO7h1U9PwBj34K2zGUQD8ClPU5W7D +sdp9b8zrSTbN2V1aEXqQoF4Efz7mO1d0HQK0IG1lbWRtcCBnaXQgPG1lbWRtcEBt +ZW1ld2FyZS5uZXQ+iHgEMBYKACAWIQS1RnePBrvMjsFn2zzZGXBkh7i23gUCZuE7 +LAIdIAAKCRDZGXBkh7i23pQsAP9y96dG0OZ02NBHXkEUWfkKrkTeaNTaho4bccsx ++fb2+AD+ODcnuTt/E6NZhOC3jkzL/wpwm0XtCxrMXmXf43LZmwKIkwQTFgoAOxYh +BLVGd48Gu8yOwWfbPNkZcGSHuLbeBQJm4TsjAhsDBQsJCAcCAiICBhUKCQgLAgQW +AgMBAh4HAheAAAoJENkZcGSHuLbeUVgBAKTeCPrHTd0YDfA+Bwwmrwc9CzR/PXTn +zR4BpApq3ro5AQCP3NByfBTfYaY0BqoRxkWBS4gSw3cly3cQ2BBbt+thALg4BGbh +OK8SCisGAQQBl1UBBQEBB0C3RzBpJD0UFcCV64bv7EfB/3tqb5onM+Qq9FurKyte +fAMBCAeIeAQYFgoAIAIbDBYhBLVGd48Gu8yOwWfbPNkZcGSHuLbeBQJm4Tp2AAoJ +ENkZcGSHuLbeE9QA/1USt07TSOzMm47ffajCC+rDQEiGvpu19dP8khPsUUUcAPwI +C8p0np2525hjuerND23TEiYK9EoRSprm8S7UdcwzCg== +=DPPI +-----END PGP PUBLIC KEY BLOCK-----`)
\ No newline at end of file diff --git a/src/routes/canaries/keystore.ts b/src/routes/canaries/keystore.ts index af710ec..1d2aefb 100644 --- a/src/routes/canaries/keystore.ts +++ b/src/routes/canaries/keystore.ts @@ -1,7 +1,10 @@ +import { dev } from "$app/environment"; import { PublicKey, readCleartextMessage, readKey, verify } from "openpgp"; +import { fallbackKeys } from "./fallback-keys"; export const keyStore = new Map<string, PublicKey>(); -export const validateSignature = async (message: string, id: string) => { - await initKeystore; +const will_debug = false; +const debug = dev && will_debug ? console.debug : () => void 0; +const _validateSignature = async (message: string, id: string) => { id = id.toUpperCase(); const key = keyStore.get(id) ?? keyStore.get(id.replace(/ /g, "")); if (!key) throw new Error("Could not find key from keystore"); @@ -14,36 +17,64 @@ export const validateSignature = async (message: string, id: string) => { expectSigned: true, }); return verificationResult.data; +} +export const validateSignature: typeof _validateSignature = async (message, id) => { + await initKeystore; + return _validateSignature(message, id) }; const pushKey = async ({ ids, key, is_url, - expectUserIds, + expect_user_ids, + expect_fingerprint, signed_by, }: { ids?: string[]; - expectUserIds?: string[]; + expect_user_ids?: string[]; + expect_fingerprint?: string; key: string; is_url?: boolean; signed_by?: string; }) => { ids = ids ?? []; if (is_url) { + const url = new URL(key, "https://keys.openpgp.org/vks/v1/by-fingerprint/"); + debug('getting key with url', url) key = await fetch( - new URL(key, "https://keys.openpgp.org/vks/v1/by-fingerprint/"), - {}, - ).then((v) => v.text()); + url, + ).then((v) => v.text()).catch(e => { + if (fallbackKeys.has(key)) { + debug('failed with error', e, 'but found fallback key') + return fallbackKeys.get(key)! + } + else { + debug('failed to fetch key, cannot find fallback') + throw e + } + }); + debug('fetched key', key) + } else { + debug('found key', key) } + if (key === null) + throw new Error('Key is null.') + if (key === '') + throw new Error('Key is empty string.') + if (typeof key !== 'string') + throw new Error(`Expected key with type string, got key of type ${key}`) if (signed_by) { - key = await validateSignature(key, signed_by); + debug('key must be signed by', signed_by) + key = await _validateSignature(key, signed_by); + debug('validated signature') } const parsedKey = await readKey({ armoredKey: key, }).then((v) => v.toPublic()); { + const ids = parsedKey.getUserIDs(); const missingUserIds = - expectUserIds?.filter((v) => !expectUserIds.includes(v)) ?? []; + expect_user_ids?.filter((v) => !ids.includes(v)) ?? []; if (missingUserIds.length) { throw new Error( `Key ${parsedKey.getFingerprint()} is missing User IDs: ${missingUserIds.join( @@ -52,24 +83,34 @@ const pushKey = async ({ ); } } + if (expect_fingerprint && parsedKey.getFingerprint().toUpperCase() !== expect_fingerprint.toUpperCase()) + throw new Error( + `Key ${parsedKey.getFingerprint()} is not ${expect_fingerprint}`, + ); + else if (expect_fingerprint) + debug('fingerprint matches expected fingerprint') + else + debug('no expected fingerprint passed') ids.push( parsedKey.getKeyID().toHex().replace(/ /g, ""), parsedKey.getFingerprint().replace(/ /g, ""), - ...(expectUserIds ?? []), + ...(expect_user_ids ?? []), ); ids = ids.filter((v, i, a) => a.indexOf(v) === i).map((v) => v.toUpperCase()); for (const id of ids) { keyStore.set(id, parsedKey); } + debug('added key', parsedKey, 'with ids', ids, 'to keystore') }; export const initKeystore = (async () => { await pushKey({ - key: "B546778F06BBCC8EC167DB3CD919706487B8B6DE", + key: 'B546778F06BBCC8EC167DB3CD919706487B8B6DE', ids: ["memdmp"], - expectUserIds: [ + expect_user_ids: [ "memdmp <memdmp@estrogen.zone>", "memdmp <memdmp@memeware.net>", ], + expect_fingerprint: 'B546778F06BBCC8EC167DB3CD919706487B8B6DE', is_url: true, }); await pushKey({ @@ -108,12 +149,12 @@ ZQ4KTbprMz8J4AD/bG33f9Kqg3AqehEyU2TldJs9U9Oni5AXGSGfKLJhmQc= `, signed_by: "memdmp <memdmp@memeware.net>", ids: ["canary-sigkey-signing"], + expect_fingerprint: '55D3582CAE78601990A8CA1DBFD0F9E61CB7D84E' }); await pushKey({ - // TODO: adapt to the relevant url on current domain when up - key: "https://files.catbox.moe/yf4x40.sig", + key: "https://git.estrogen.zone/mem-estrogen-zone.git/plain/static/keys/external/napatha.pgp.sig", ids: ["napatha"], - expectUserIds: ["chef naphtha <naphtha@kyun.host>"], + expect_user_ids: ["chef naphtha <naphtha@kyun.host>"], is_url: true, signed_by: "canary-sigkey-signing", }); diff --git a/src/routes/canaries/napatha:kyun.host/+server.ts b/src/routes/canaries/napatha:kyun.host/+server.ts index 260d15e..cca6e7e 100644 --- a/src/routes/canaries/napatha:kyun.host/+server.ts +++ b/src/routes/canaries/napatha:kyun.host/+server.ts @@ -1,5 +1,5 @@ -import { redirect } from '@sveltejs/kit'; - +// CORS moment +export const prerender = true; export const GET = () => { - throw redirect(307, 'https://files.kyun.host/canary.txt'); + return fetch('https://files.kyun.host/canary.txt'); }; |