Skip to content
This repository was archived by the owner on Apr 10, 2026. It is now read-only.
This repository was archived by the owner on Apr 10, 2026. It is now read-only.

feat: Public REST API with API key authentication #56

@dajbelshaw

Description

@dajbelshaw

Overview

Expose a versioned public REST API so users can integrate TaskDial with external tools (Zapier, n8n, home automation, scripts, custom clients). Access is granted via API keys — no OAuth flow required for the caller.


E2EE constraint (read this first)

TaskDial encrypts title, tag, and details client-side with AES-256-GCM. The encryption key is derived from password + key_salt via PBKDF2 and never leaves the browser. The server genuinely cannot decrypt task content — it only stores ciphertext for those three fields.

This creates a hard constraint for any API that wants to return readable task data.

Option A — Ciphertext passthrough

Return ciphertext as-is for title/tag/details. Preserves E2EE completely, but callers must implement PBKDF2 + AES-256-GCM to read those fields. Unusable for no-code tools (Zapier, n8n, etc.).

Option B — Plaintext API mode (v1 recommendation)

API-created tasks are stored unencrypted. App-created (encrypted) tasks return ciphertext for the three fields with a flag so callers know what they're getting. The frontend already handles this — decryptTask() passes non-encrypted values through unchanged (the legacy data fallback at api.ts:128). No frontend changes required.

Trade-off: the DB contains a mix of encrypted and unencrypted tasks. The E2EE guarantee no longer applies to tasks created via API. This should be disclosed clearly in the UI when a user creates an API key.

Option C — Envelope encryption (v2 recommendation)

The only approach that gives a fully usable API and preserves E2EE:

At API key creation time (user is in the browser, derived CryptoKey is in sessionStorage):

  1. Browser generates the raw API key: td_<40 random hex chars>
  2. Browser derives a wrapping key: PBKDF2(raw_api_key, random_salt)
  3. Browser wraps the user's current AES key: wrapped_key = AES-GCM-Encrypt(user_crypto_key, wrapping_key)
  4. Sends to server: { key_hash: SHA-256(raw_key), wrapped_key, wrap_salt } — raw key never stored

At API request time:

  1. Caller presents Authorization: Bearer td_<raw_key>
  2. Server looks up the key by SHA-256(raw_key), retrieves wrapped_key + wrap_salt
  3. Server derives the unwrapping key: PBKDF2(raw_key, wrap_salt) — possible because the raw key is in memory for this request
  4. Server unwraps → recovers user's AES key → decrypts task fields → returns plaintext JSON

Security properties:

  • DB breach: attacker has key_hash (SHA-256, useless for decryption) + wrapped_key (useless without the raw API key). Task content stays encrypted. ✓
  • Active server compromise during a live request: server sees the raw key and could decrypt. This is the same exposure as the current login flow, where the server sees the plaintext password during bcrypt.compare and could derive the encryption key. The E2EE guarantee is about passive reads of stored data, not a malicious server. ✓

Password reset interaction: Password reset rotates key_salt, changing the derived encryption key. For envelope encryption, API keys wrap the pre-reset key and become invalid. See password reset section below.


Phased implementation

Phase 1 (v1): Option B — plaintext for API-created tasks. Simple, no crypto changes, honest about the trade-off. Disclosed in the key creation UI.

Phase 2 (v2): Option C — envelope encryption. Upgrade path for users who want E2EE + API access. Requires additional crypto in the key creation flow and server-side decryption at request time.


API key management

Database

New api_keys table:

CREATE TABLE api_keys (
  id TEXT PRIMARY KEY,
  user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
  name TEXT NOT NULL,
  key_hash TEXT NOT NULL UNIQUE,   -- SHA-256 of raw key (v1 + v2)
  wrapped_key TEXT,                -- envelope-encrypted user AES key (v2 only, NULL in v1)
  wrap_salt TEXT,                  -- PBKDF2 salt for wrapping key (v2 only)
  last_used_at TEXT,
  expires_at TEXT,                 -- NULL = no expiry
  created_at TEXT NOT NULL,
  revoked INTEGER NOT NULL DEFAULT 0
);
CREATE INDEX idx_api_keys_user ON api_keys(user_id);

Key format

td_<40 random hex chars>. Prefix makes keys identifiable in logs and secret scanners.

Endpoints (require jwtAuth — managed from the app UI)

Method Path Description
GET /api/user/api-keys List keys (name, id, last_used_at, expires_at — never raw key)
POST /api/user/api-keys Create key — returns raw key once only
DELETE /api/user/api-keys/:id Revoke key

Body for creation: { "name": "My Script", "expiresAt": "2027-01-01" } (expiresAt optional).

2FA interaction: If the user has 2FA enabled (see #51), creating or revoking an API key must require a current TOTP code. Accept an optional totpCode field in the request body and enforce it when totp_enabled = 1. This prevents an attacker with a live session cookie from minting a persistent credential that survives 2FA protection.

Password reset

Password reset is a full credential wipe. The reset-password handler (see #51) revokes all API keys as part of its sequence:

UPDATE api_keys SET revoked = 1 WHERE user_id = ?

This applies in both v1 and v2:

  • v1: revoked for security hygiene — the old session should not persist
  • v2: required for correctness — wrapped keys use the pre-reset AES key, which is now permanently invalid

Users must regenerate API keys after a password reset. Document this in the settings UI and in the OpenAPI spec.


Authentication

New apiKeyAuth middleware for /api/v1/* routes:

  1. Extract raw key from Authorization: Bearer header
  2. Hash it (SHA-256), look up in api_keys where revoked=0 and not expired
  3. Load user; confirm is_active=1
  4. Set req.user (same shape as jwtAuth), update last_used_at
  5. If wrapped_key is present (v2): unwrap and attach decryption key to request context
  6. On failure: 401

Versioned routes

Mount at /api/v1/. Independent from the existing /api/ internal routes.

Tasks

Method Path Description
GET /api/v1/tasks List tasks
POST /api/v1/tasks Create a task
GET /api/v1/tasks/:id Get a task
PATCH /api/v1/tasks/:id Update a task
DELETE /api/v1/tasks/:id Delete a task

Query parameters for GET /api/v1/tasks:

  • date=YYYY-MM-DD — filter by date
  • from=YYYY-MM-DD&to=YYYY-MM-DD — date range
  • completed=true|false
  • tag=<value>
  • limit=50&offset=0 — pagination (max 200 per page)

Bulk endpoint — coordinate with #55: A /api/v1/tasks/bulk endpoint for bulk creation (POST array, single transaction, cap 500) is useful both for the import feature and for API consumers. Design the internal /api/tasks/bulk endpoint first (used by the import UI, cookie-auth only, always encrypted); the public /api/v1/tasks/bulk wraps it with API key auth and v1 plaintext semantics. Keep them separate so the internal endpoint can never be reached via API key auth.

Response shape for encrypted fields (v1):

Tasks created via API return plaintext for all fields. App-created tasks return ciphertext for title/tag/details with an encrypted flag:

{
  "id": "...",
  "title": "enc:a3f9c2...",
  "tag": null,
  "details": null,
  "encrypted": true,
  "date": "2026-04-01",
  "completed": false
}

In v2 (envelope encryption), encrypted is always false — the middleware decrypts transparently.


Rate limiting

New apiLimiter: 120 requests per minute per API key. Return Retry-After header on 429.


Settings UI

Add an "API keys" section to the Settings panel:

  • Table: key name, created date, last used date, expiry (or "Never"), Revoke button
  • "Create new key" form: name field, optional expiry date picker, TOTP code field (shown only when 2FA is enabled)
  • On creation: modal showing raw key once with copy button and warning: "This key will not be shown again."
  • v1 disclosure: "Tasks created via the API are stored without end-to-end encryption."
  • Password reset notice: "API keys are revoked when you reset your password and must be regenerated."
  • Empty state: "No API keys yet. Create one to integrate TaskDial with external tools."

OpenAPI spec

Publish an openapi.yaml at /api/v1/openapi.json describing all v1 endpoints. Hand-maintained initially.


Out of scope (for now)

  • OAuth 2.0 / third-party app authorisation
  • Webhook delivery
  • Scoped permissions per key (read-only vs read-write)
  • SDK / client libraries
  • Rate limit tiers

Metadata

Metadata

Assignees

No one assigned

    Labels

    authAuthentication & sessionsenhancementNew feature or requestsecuritySecurity concern

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions