excel file update

This commit is contained in:
sriram
2026-08-27 16:41:25 +05:30
parent a9ab1fcf71
commit f698720ee2
7 changed files with 411 additions and 4 deletions

View File

@@ -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"),
}

View File

@@ -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