Decrypting Your Godspeed Tasks: Working with E2EE API Data
I recently moved from OmniFocus to Godspeed as my primary task manager and haven't looked back. The app is incredibly fast, customizable, and - most importantly - can be used entirely with keyboard shortcuts without ever touching your mouse. Plus, the developer is responsive and helpful at all times.
It comes with E2EE and an API. Naturally, if you use E2EE, the API responses will be encrypted as well. This post outlines how you can decrypt your tasks, labels, and lists when using the API. At the end, you'll also find Node.js code to perform the decryption for you.
Encryption details
Godspeed uses AES-256-GCM to encrypt and decrypt the messages. When fetching a task, you'll find a field called encrypted_title
with a structure that looks like this:
"encrypted_title": "{\"ct\":\"FsWpgbXXXXXX\",\"iv\":\"YfSLBXXXXXX\",\"tag\":\"3zFzFXXXXX\",\"sk\":\"CuVkUVDNUaFivi4jXXXXXXXXXXXXX\",\"eu\":1XXXX}",
These fields are base64 encoded and denote:
ct
: Ciphertext (encrypted message)iv
: Initialization Vector (12 bytes for AES-GCM)tag
: Authentication Tag (16 bytes for AES-GCM)sk
: Session Key (encrypted AES key)
Wait, but how is the E2EE ensured when the key is passed along? The answer is that the key is actually encrypted, with your encryption private key
that can be exported from your app:
The Session Key is encrypted using RSA-OAEP with a SHA-256 hash and needs decryption to have a proper decrypted key for the actual title of your task.
Decryption Process
Step 1: Decrypt the RSA Session Key
This logic loads the private key you have exported from Godspeed anduses it to decrypt your Session Key:
const privateKey = fs.readFileSync(privateKeyPath, "utf8");
const encryptedSessionKey = Buffer.from(encryptedPackage.sk, 'base64');
const sessionKeyBase64 = crypto.privateDecrypt({
key: privateKey,
padding: crypto.constants.RSA_PKCS1_OAEP_PADDING,
oaepHash: 'sha256'
}, encryptedSessionKey).toString('utf8');
Step 2: Session Key Processing
Convert the base64 string back to a 32-byte buffer for AES decryption:
const sessionKey = Buffer.from(sessionKeyBase64, 'base64');
Step 3: AES-GCM Decryption
const ciphertext = Buffer.from(encryptedPackage.ct, 'base64');
const authTag = Buffer.from(encryptedPackage.tag, 'base64');
const iv = Buffer.from(encryptedPackage.iv, 'base64');
const decipher = crypto.createDecipheriv('aes-256-gcm', sessionKey, iv);
decipher.setAuthTag(authTag);
let decrypted = decipher.update(ciphertext);
decrypted = Buffer.concat([decrypted, decipher.final()]);
return decrypted.toString('utf8');
Full script
Here's the complete script that ties everything together. Make sure to set the right permissions on the private key file to prevent unauthorized access and never commit it to version control:!
const fs = require("fs");
const crypto = require("crypto");
// Decrypt hybrid encrypted data (RSA + AES-GCM)
const decryptData = (encryptedPackage, privateKeyPath) => {
// Step 1: Decrypt the session key with RSA-OAEP
const privateKey = fs.readFileSync(privateKeyPath, "utf8");
const encryptedSessionKey = Buffer.from(encryptedPackage.sk, "base64");
const sessionKeyBase64 = crypto
.privateDecrypt(
{
key: privateKey,
padding: crypto.constants.RSA_PKCS1_OAEP_PADDING,
oaepHash: "sha256",
},
encryptedSessionKey,
)
.toString("utf8");
// Step 2: Convert base64 session key to buffer
const sessionKey = Buffer.from(sessionKeyBase64, "base64");
// Step 3: Decrypt the message with AES-256-GCM
const ciphertext = Buffer.from(encryptedPackage.ct, "base64");
const authTag = Buffer.from(encryptedPackage.tag, "base64");
const iv = Buffer.from(encryptedPackage.iv, "base64");
const decipher = crypto.createDecipheriv("aes-256-gcm", sessionKey, iv);
decipher.setAuthTag(authTag);
let decrypted = decipher.update(ciphertext);
decrypted = Buffer.concat([decrypted, decipher.final()]);
return decrypted.toString("utf8");
};
// Example usage
const encryptedData = {
ct: "",
iv: "",
tag: "",
sk: "",
};
try {
const result = decryptData(encryptedData, "./key.pem");
console.log("Decrypted message:", result);
} catch (error) {
console.error("Decryption failed:", error.message);
}