Add Komoot import with public (bio verification) and authenticated modes
Two-mode import: public mode verifies Komoot account ownership by checking that the user's trails.cool profile URL appears in their Komoot bio — no credentials stored. Authenticated mode uses email + password (AES-256-GCM encrypted) to import private tours as well. Includes unit tests for crypto/komoot client and E2E tests for the full connect + import flow. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
b63fd1a303
commit
03304c354b
22 changed files with 1612 additions and 4 deletions
41
apps/journal/app/lib/crypto.server.ts
Normal file
41
apps/journal/app/lib/crypto.server.ts
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
// AES-256-GCM encrypt/decrypt for storing sensitive integration credentials.
|
||||
// Key is derived from INTEGRATION_SECRET env var using scrypt.
|
||||
//
|
||||
// Encoded format: base64(salt_hex:iv_hex:ciphertext_hex:authtag_hex)
|
||||
// All fields are hex-encoded before joining so the separator is unambiguous.
|
||||
|
||||
import { createCipheriv, createDecipheriv, randomBytes, scryptSync } from "node:crypto";
|
||||
|
||||
const SALT = "trails-cool-integrations";
|
||||
const KEY_LEN = 32; // AES-256
|
||||
const IV_LEN = 12; // GCM standard
|
||||
|
||||
function getKey(): Buffer {
|
||||
const secret = process.env.INTEGRATION_SECRET;
|
||||
if (!secret) throw new Error("INTEGRATION_SECRET env var is not set");
|
||||
return scryptSync(secret, SALT, KEY_LEN) as Buffer;
|
||||
}
|
||||
|
||||
export function encrypt(plaintext: string): string {
|
||||
const key = getKey();
|
||||
const iv = randomBytes(IV_LEN);
|
||||
const cipher = createCipheriv("aes-256-gcm", key, iv);
|
||||
const encrypted = Buffer.concat([cipher.update(plaintext, "utf8"), cipher.final()]);
|
||||
const authTag = cipher.getAuthTag();
|
||||
const payload = [iv.toString("hex"), encrypted.toString("hex"), authTag.toString("hex")].join(":");
|
||||
return Buffer.from(payload).toString("base64");
|
||||
}
|
||||
|
||||
export function decrypt(encoded: string): string {
|
||||
const key = getKey();
|
||||
const payload = Buffer.from(encoded, "base64").toString("utf8");
|
||||
const parts = payload.split(":");
|
||||
if (parts.length !== 3) throw new Error("Invalid encrypted payload format");
|
||||
const [ivHex, encryptedHex, authTagHex] = parts as [string, string, string];
|
||||
const iv = Buffer.from(ivHex, "hex");
|
||||
const encrypted = Buffer.from(encryptedHex, "hex");
|
||||
const authTag = Buffer.from(authTagHex, "hex");
|
||||
const decipher = createDecipheriv("aes-256-gcm", key, iv);
|
||||
decipher.setAuthTag(authTag);
|
||||
return decipher.update(encrypted) + decipher.final("utf8");
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue