Last active
August 10, 2025 02:57
-
-
Save pckilgore/1aba3fe9194d763b3365e86e79f8fb86 to your computer and use it in GitHub Desktop.
Godspeed.app Encrypted Field Decryptor
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| /* | |
| 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