excel file update
This commit is contained in:
@@ -186,6 +186,40 @@ def password_hash_fingerprint(encoded: str) -> str:
|
||||
return hashlib.sha256(encoded.strip().strip("'\"").encode("utf-8")).hexdigest()[:12]
|
||||
|
||||
|
||||
def api_key_fingerprint(name: str, secret: str) -> str:
|
||||
"""
|
||||
A short, non-reversible identifier for a configured API key.
|
||||
|
||||
Same purpose as password_hash_fingerprint - say *which* credential is loaded
|
||||
without moving the credential - but the safety argument is different and
|
||||
worth stating. That function digests an encoded hash which already embeds a
|
||||
16-byte random salt. An API key has no salt, so the name is mixed in here to
|
||||
keep two consumers that were mistakenly issued the same secret from
|
||||
fingerprinting identically, and settings._parse_api_keys enforces a minimum
|
||||
secret length so the digest cannot be walked back with a wordlist.
|
||||
"""
|
||||
if not secret:
|
||||
return ""
|
||||
cleaned = secret.strip().strip("'\"")
|
||||
material = f"{name}:{cleaned}"
|
||||
return hashlib.sha256(material.encode("utf-8")).hexdigest()[:12]
|
||||
|
||||
|
||||
def describe_api_keys() -> List[Dict[str, object]]:
|
||||
"""Every configured key as {name, role, fingerprint}, sorted by name.
|
||||
|
||||
Sorted so two deployments' /api/health output can be diffed line for line;
|
||||
API_KEYS is keyed by secret, whose iteration order says nothing useful.
|
||||
"""
|
||||
return sorted(
|
||||
(
|
||||
{"name": name, "role": role, "fingerprint": api_key_fingerprint(name, secret)}
|
||||
for secret, (name, role) in API_KEYS.items()
|
||||
),
|
||||
key=lambda entry: entry["name"],
|
||||
)
|
||||
|
||||
|
||||
def describe_password_hash(encoded: str) -> Dict[str, object]:
|
||||
"""A loggable/publishable summary of a configured digest. Never its bytes."""
|
||||
parsed = _parse_encoded_hash(encoded)
|
||||
@@ -211,6 +245,13 @@ def auth_config_summary() -> Dict[str, object]:
|
||||
means the container's own environment supplied it and the .env file baked
|
||||
into the image was ignored - which is invisible from anywhere else, and is
|
||||
precisely how a corrected credential can keep failing after a redeploy.
|
||||
|
||||
The same argument is why the API keys are summarised here. backend/Dockerfile
|
||||
copies .env.production in at BUILD time, so a key added to that file and then
|
||||
merely restarted is not present in the running process - and from outside,
|
||||
an undeployed key is indistinguishable from a wrong one, because both are
|
||||
just a 401. Publishing the names and fingerprints answers "is my key on this
|
||||
deployment?" without anyone having to send the secret to find out.
|
||||
"""
|
||||
described = describe_password_hash(AUTH_ADMIN_PASSWORD_HASH)
|
||||
return {
|
||||
@@ -222,6 +263,9 @@ def auth_config_summary() -> Dict[str, object]:
|
||||
"password_hash_fingerprint": described["fingerprint"],
|
||||
"admin_username_source": config_source("AUTH_ADMIN_USERNAME"),
|
||||
"password_hash_source": config_source("AUTH_ADMIN_PASSWORD_HASH"),
|
||||
"api_keys_count": len(API_KEYS),
|
||||
"api_keys": describe_api_keys(),
|
||||
"api_keys_source": config_source("API_KEYS"),
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -386,6 +386,12 @@ AUTH_LOCKOUT_SECONDS = int(os.getenv("AUTH_LOCKOUT_SECONDS", "300"))
|
||||
AUTH_ALLOW_ANY_LOGIN = _bool("AUTH_ALLOW_ANY_LOGIN", "false")
|
||||
|
||||
|
||||
# Shortest acceptable API key secret. token_urlsafe(32) yields 43 characters, so
|
||||
# this rejects hand-typed values without rejecting anything the documented
|
||||
# generator produces.
|
||||
API_KEY_MIN_LENGTH = 32
|
||||
|
||||
|
||||
def _parse_api_keys(raw: str) -> dict:
|
||||
"""
|
||||
Parse ``API_KEYS`` - ``name:role:secret`` triples, comma-separated.
|
||||
@@ -393,6 +399,16 @@ def _parse_api_keys(raw: str) -> dict:
|
||||
Keyed by secret because that is what an inbound request presents. One entry
|
||||
per consumer is the point: a shared key cannot be revoked for one caller
|
||||
without breaking all of them.
|
||||
|
||||
Secrets must be at least API_KEY_MIN_LENGTH characters. That is not about
|
||||
guessing the key over the network - the lockout and the network itself make
|
||||
online brute force impractical - but about what /api/health publishes. It
|
||||
reports a truncated digest of every configured key so a deployment can be
|
||||
checked against the config it was built from, and a digest of a *raw* secret
|
||||
is only safe when the secret is unguessable offline. An admin password hash
|
||||
embeds a random salt, so its fingerprint discloses nothing; an API key has no
|
||||
salt, and a hand-picked "changeme" would fall to a wordlist in seconds.
|
||||
Generate one with: python -c "import secrets; print(secrets.token_urlsafe(32))"
|
||||
"""
|
||||
parsed: dict = {}
|
||||
for entry in raw.split(","):
|
||||
@@ -412,6 +428,13 @@ def _parse_api_keys(raw: str) -> dict:
|
||||
)
|
||||
if not secret:
|
||||
raise RuntimeError(f"API_KEYS entry {name!r} has an empty secret.")
|
||||
if len(secret) < API_KEY_MIN_LENGTH:
|
||||
raise RuntimeError(
|
||||
f"API_KEYS entry {name!r} has a {len(secret)}-character secret; at least "
|
||||
f"{API_KEY_MIN_LENGTH} are required, because /api/health publishes a digest "
|
||||
f"of it. Generate one with: "
|
||||
f"python -c \"import secrets; print(secrets.token_urlsafe(32))\""
|
||||
)
|
||||
parsed[secret] = (name, role)
|
||||
return parsed
|
||||
|
||||
|
||||
Reference in New Issue
Block a user