Things I learned

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:

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:

Godspeed key export

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);
}

#e2ee #encryption #godspeed #task-management