Skip to content

Instantly share code, notes, and snippets.

@pckilgore
Last active August 10, 2025 02:57
Show Gist options
  • Select an option

  • Save pckilgore/1aba3fe9194d763b3365e86e79f8fb86 to your computer and use it in GitHub Desktop.

Select an option

Save pckilgore/1aba3fe9194d763b3365e86e79f8fb86 to your computer and use it in GitHub Desktop.
Godspeed.app Encrypted Field Decryptor
/*
If you use godspeedapp.com (it's great, tell Daniel I sent you, but if you found this
you probably already use it) and want to use the API without disabling your E2E encryption
this code provides a template for you to do so. Have a generative model turn it into
your language of choice.
**NOTE** #init() expects a trimmed key.
**NOTE** Don't ship your fucking private key. Load it from a secret store you
trust according to your threat model.
Released under MIT.
*/
type EncryptedData = {
ct: string; // Ciphertext (base64 encoded)
iv: string; // Initialization Vector (base64 encoded)
tag: string; // Authentication tag (base64 encoded)
sk: string; // Encrypted symmetric key (base64 encoded)
eu: number; // User ID
};
export class GodspeedDecryptor {
private _key: CryptoKey | null = null;
private key(): CryptoKey {
if (this._key === null) {
throw Error(
"cannot continue without private key, GodspeedDecryptor.init() first",
);
}
return this._key;
}
// Initialize the decryptor with a key. The ----- BEGIN PRIVATE KEY -----
// header AND FOOTER should be stripped before this.
async init(base64Key: string) {
const data = await base64ToBytes(base64Key);
this._key = await crypto.subtle.importKey(
"pkcs8",
new Uint8Array(data),
{ name: "RSA-OAEP", hash: "SHA-256" },
false,
["decrypt"],
);
}
// Decrypts the value of an encrypted field
async decrypt(encryptedString: string): Promise<string> {
const data: EncryptedData = JSON.parse(encryptedString);
const skBytes = new Uint8Array(await base64ToBytes(data.sk));
const symmetricKeyBuffer: ArrayBuffer = await crypto.subtle.decrypt(
{ name: "RSA-OAEP" },
this.key(),
skBytes,
);
// The symmetric key is base64 encoded, so decode it.
const symmetricKeyBase64 = new TextDecoder().decode(symmetricKeyBuffer);
const symmetricKeyRaw = new Uint8Array(
await base64ToBytes(symmetricKeyBase64),
);
// Create a decryption key.
const symmetricKey: CryptoKey = await crypto.subtle.importKey(
"raw",
symmetricKeyRaw,
{ name: "AES-GCM" },
false,
["decrypt"],
);
// Prepare components for AES-GCM decryption
const [iv, ciphertext, tag] = await Promise.all(
[data.iv, data.ct, data.tag].map(base64ToBytes),
).then((buffers) => buffers.map((buffer) => new Uint8Array(buffer)));
// Combine ciphertext and tag for AES-GCM (tag must be appended)
const encryptedBuffer: Uint8Array = new Uint8Array(
ciphertext.length + tag.length,
);
encryptedBuffer.set(ciphertext, 0);
encryptedBuffer.set(tag, ciphertext.length);
// Decrypt the actual data
const decryptedBuffer: ArrayBuffer = await crypto.subtle.decrypt(
{ name: "AES-GCM", iv: iv },
symmetricKey,
encryptedBuffer,
);
return new TextDecoder().decode(decryptedBuffer);
}
}
// Convert base64 data to raw bytes.
async function base64ToBytes(base64: string): Promise<ArrayBuffer> {
const data = await fetch(`data:application/octet-stream;base64,${base64}`);
return await data.arrayBuffer();
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment