API keys revealed once: store fingerprint (last-4) + hash, purge plaintext
This commit is contained in:
1 parent
6564508e1d
commit
c213a489e9
1 file changed
+27
-5
+27
-5
@@ -282,6 +282,23 @@ def get_db() -> sqlite3.Connection:
|
|||||||
# used to throttle re-invites of members who never activated.
|
# used to throttle re-invites of members who never activated.
|
||||||
if "welcome_sent_at" not in cols:
|
if "welcome_sent_at" not in cols:
|
||||||
conn.execute("ALTER TABLE members ADD COLUMN welcome_sent_at TEXT")
|
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
|
return conn
|
||||||
|
|
||||||
|
|
||||||
@@ -899,12 +916,17 @@ async def broker_create_api_key(email: str, name: str) -> dict:
|
|||||||
sk_token = data.get("key", "")
|
sk_token = data.get("key", "")
|
||||||
h = data.get("token") or data.get("token_id") or ""
|
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 = get_db()
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"INSERT INTO member_api_keys (email, name, sk_token, hash) VALUES (?, ?, ?, ?) "
|
"INSERT INTO member_api_keys (email, name, sk_token, hash, fingerprint) VALUES (?, ?, ?, ?, ?) "
|
||||||
"ON CONFLICT(email, name) DO UPDATE SET sk_token=excluded.sk_token, hash=excluded.hash, "
|
"ON CONFLICT(email, name) DO UPDATE SET sk_token='', hash=excluded.hash, fingerprint=excluded.fingerprint, "
|
||||||
"created_at=datetime('now')",
|
"created_at=datetime('now')",
|
||||||
(email, name, sk_token, h),
|
(email, name, "", h, fingerprint),
|
||||||
)
|
)
|
||||||
conn.commit()
|
conn.commit()
|
||||||
row = conn.execute(
|
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]:
|
async def broker_list_api_keys(email: str) -> list[dict]:
|
||||||
conn = get_db()
|
conn = get_db()
|
||||||
rows = conn.execute(
|
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,),
|
(email,),
|
||||||
).fetchall()
|
).fetchall()
|
||||||
conn.close()
|
conn.close()
|
||||||
return [
|
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
|
for r in rows
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user