diff --git a/app/main.py b/app/main.py index ee5272c..dafdce1 100644 --- a/app/main.py +++ b/app/main.py @@ -282,6 +282,23 @@ def get_db() -> sqlite3.Connection: # used to throttle re-invites of members who never activated. if "welcome_sent_at" not in cols: conn.execute("ALTER TABLE members ADD COLUMN welcome_sent_at TEXT") + + # Member-managed API keys: add a fingerprint (last-4 of the key, shown in + # lists) and purge the stored plaintext. Member API keys are revealed ONCE + # (at creation) and never again — the industry standard. Revocation uses the + # LiteLLM hash, not the plaintext, so the portal has no reason to keep it. + # (The `members.key_token` chat key is a different table and IS retained in + # plaintext — the injector needs it on every request.) + ak_cols = [r[1] for r in conn.execute("PRAGMA table_info(member_api_keys)").fetchall()] + if "fingerprint" not in ak_cols: + conn.execute("ALTER TABLE member_api_keys ADD COLUMN fingerprint TEXT") + # Backfill fingerprints for keys that already have plaintext stored, + # then purge the plaintext. (fingerprint = last-4 of the key.) + conn.execute( + "UPDATE member_api_keys SET fingerprint = substr(sk_token, -4) " + "WHERE fingerprint IS NULL AND sk_token != ''" + ) + conn.execute("UPDATE member_api_keys SET sk_token = '' WHERE sk_token != ''") return conn @@ -899,12 +916,17 @@ async def broker_create_api_key(email: str, name: str) -> dict: sk_token = data.get("key", "") h = data.get("token") or data.get("token_id") or "" + # Reveal the key ONCE (in the response). Persist only a fingerprint (last-4) + # and the LiteLLM hash — the plaintext is NOT stored. Revocation uses the + # hash, so the full key never needs to be retained. + fingerprint = sk_token[-4:] if sk_token else "" + conn = get_db() conn.execute( - "INSERT INTO member_api_keys (email, name, sk_token, hash) VALUES (?, ?, ?, ?) " - "ON CONFLICT(email, name) DO UPDATE SET sk_token=excluded.sk_token, hash=excluded.hash, " + "INSERT INTO member_api_keys (email, name, sk_token, hash, fingerprint) VALUES (?, ?, ?, ?, ?) " + "ON CONFLICT(email, name) DO UPDATE SET sk_token='', hash=excluded.hash, fingerprint=excluded.fingerprint, " "created_at=datetime('now')", - (email, name, sk_token, h), + (email, name, "", h, fingerprint), ) conn.commit() row = conn.execute( @@ -918,12 +940,12 @@ async def broker_create_api_key(email: str, name: str) -> dict: async def broker_list_api_keys(email: str) -> list[dict]: conn = get_db() rows = conn.execute( - "SELECT name, sk_token, created_at FROM member_api_keys WHERE email = ? ORDER BY created_at DESC", + "SELECT name, fingerprint, created_at FROM member_api_keys WHERE email = ? ORDER BY created_at DESC", (email,), ).fetchall() conn.close() return [ - {"name": r[0], "sk_token": r[1], "created_at": r[2]} + {"name": r[0], "fingerprint": r[1] or "", "created_at": r[2]} for r in rows ]