Skip to content

Instantly share code, notes, and snippets.

@tmcw

tmcw/inbox.ts Secret

Created December 8, 2022 16:02
Show Gist options
  • Star 1 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save tmcw/7394bc8588a63399bea23d15a34fa2fa to your computer and use it in GitHub Desktop.
Save tmcw/7394bc8588a63399bea23d15a34fa2fa to your computer and use it in GitHub Desktop.
import base64 from "https://deno.land/x/b64@1.1.24/src/base64.js";
import { z } from "https://esm.sh/zod@3.19.1";
import { connect } from "https://esm.sh/@planetscale/database";
import { Context } from "https://edge.netlify.com/";
const DOMAIN = "macwright.com";
const ALGORITHM = "RSASSA-PKCS1-v1_5";
const HASH = "SHA-256";
const Follow = z
.object({
type: z.literal("Follow"),
object: z.string(),
actor: z.string().url(),
})
.passthrough();
type IFollow = z.infer<typeof Follow>;
interface AcceptMessage {
"@context": "https://www.w3.org/ns/activitystreams";
id: string;
type: "Accept";
actor: string;
object: IFollow;
}
/*
Convert a string into an ArrayBuffer
from https://developers.google.com/web/updates/2012/06/How-to-convert-ArrayBuffer-to-and-from-String
*/
function str2ab(str: string) {
const buf = new ArrayBuffer(str.length);
const bufView = new Uint8Array(buf);
for (let i = 0, strLen = str.length; i < strLen; i++) {
bufView[i] = str.charCodeAt(i);
}
return buf;
}
/*
Import a PEM encoded RSA private key, to use for RSA-PSS signing.
Takes a string containing the PEM encoded key, and returns a Promise
that will resolve to a CryptoKey representing the private key.
TODO: this uses a bunch of bad, old techniques.
My first shot at translating it to "good" techniques
failed. Needs a second.
*/
function importPrivateKey(pem: string) {
// fetch the part of the PEM string between header and footer
const pemHeader = "-----BEGIN PRIVATE KEY-----";
const pemFooter = "-----END PRIVATE KEY-----";
const pemContents = pem.substring(
pemHeader.length,
pem.length - pemFooter.length,
);
// base64 decode the string to get the binary data
const binaryDerString = window.atob(pemContents);
// convert from a binary string to an ArrayBuffer
const binaryDer = str2ab(binaryDerString);
return window.crypto.subtle.importKey(
"pkcs8",
binaryDer,
{
name: ALGORITHM,
hash: HASH,
},
true,
["sign"],
);
}
// Get that private key…
const privkey = ``;
function encodeText(text: string) {
const encoder = new TextEncoder();
return encoder.encode(text);
}
async function digestMessage(message: string) {
const data = encodeText(message);
const hash = await crypto.subtle.digest("SHA-256", data);
return base64.fromArrayBuffer(hash);
}
async function signAndSend(
message: AcceptMessage,
name: string,
domain: string,
targetDomain: string,
) {
// get the URI of the actor object and append 'inbox' to it
const inbox = message.object.actor + "/inbox";
const inboxFragment = inbox.replace("https://" + targetDomain, "");
const digestHash = await digestMessage(JSON.stringify(message));
const d = new Date();
const stringToSign = encodeText(
`(request-target): post ${inboxFragment}\nhost: ${targetDomain}\ndate: ${d.toUTCString()}\ndigest: SHA-256=${digestHash}`,
);
const signature = base64.fromArrayBuffer(
await crypto.subtle.sign(
{
name: ALGORITHM,
hash: { name: HASH },
},
await importPrivateKey(privkey),
stringToSign,
),
);
const header =
`keyId="https://${domain}/u/${name}",headers="(request-target) host date digest",signature="${signature}"`;
await fetch(inbox, {
headers: new Headers({
Host: targetDomain,
Date: d.toUTCString(),
Digest: `SHA-256=${digestHash}`,
"Content-Type": "application/json",
Signature: header,
}),
method: "POST",
body: JSON.stringify(message),
})
.then(async (res) => {
if (res.status >= 400) {
console.error("Failed request", {
res: await res.text(),
header,
signature,
targetDomain,
});
return;
}
return res.text();
})
.then((res) => {
console.log("Response:", res);
})
.catch((e) => console.error(e));
}
function sendAcceptMessage(
thebody: IFollow,
name: string,
domain: string,
targetDomain: string,
) {
const guid = self.crypto.randomUUID();
const message: AcceptMessage = {
"@context": "https://www.w3.org/ns/activitystreams",
id: `https://${domain}/${guid}`,
type: "Accept",
actor: `https://${domain}/u/${name}`,
object: thebody,
};
return signAndSend(message, name, domain, targetDomain);
}
const config = {
url: Deno.env.get("DATABASE_URL"),
};
export default async function handler(req: Request, _context: Context) {
if (req.method !== "POST") {
return new Response("", {
status: 404,
headers: { "Content-Type": "text/html" },
});
}
const followRequest = Follow.safeParse(await req.json());
if (!followRequest.success) {
return new Response("", {
status: 200,
headers: { "Content-Type": "text/html" },
});
}
const f = followRequest.data;
// TODO: add "Undo" follow event
const myURL = new URL(f.actor);
const targetDomain = myURL.hostname;
const name = f.object.replace(`https://${DOMAIN}/u/`, "");
if (name !== "photos") {
return new Response("", {
status: 404,
headers: { "Content-Type": "text/html" },
});
}
console.log("got follow request from ", f.actor);
await sendAcceptMessage(f, name, DOMAIN, targetDomain);
const conn = connect(config);
await conn.execute(
"insert ignore into followers (target, follower) values (?, ?)",
[
name,
f.actor,
],
);
console.log("updated followers!", f.actor);
return new Response("", {
status: 200,
headers: { "Content-Type": "text/html" },
});
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment