security: replace retired MFA diagnostic credential discovery with scoped checks
Assistant: codex Assistant-Model: gpt-6-astra Assistant-Session: 01a1346b-72a4-7d40-b8bd-8b8ca346cfa2
This commit is contained in:
parent
1cbe00fd78
commit
96feef46ab
9 changed files with 635 additions and 94 deletions
|
|
@ -27,7 +27,7 @@ runbook or a dedicated procedure. Missing runbooks are listed separately.
|
|||
| `postgresql/create-secrets.sh` | [README.md](../sso-mfa/k8s/postgresql/README.md), [smooth-bootstrap-guide.md](../docs/smooth-bootstrap-guide.md) | unknown |
|
||||
| `privacyidea/bootstrap-admin.sh` | [README.md](../sso-mfa/k8s/privacyidea/README.md) | unknown |
|
||||
| `privacyidea/bootstrap-realm.sh` | [keycape-exposure-resolver-reconciliation.md](../docs/keycape-exposure-resolver-reconciliation.md) | unknown |
|
||||
| `privacyidea/check-user-mfa-state.sh` | [smooth-bootstrap-guide.md](../docs/smooth-bootstrap-guide.md) | unknown |
|
||||
| `privacyidea/check-user-mfa-state.sh` | [smooth-bootstrap-guide.md](../docs/smooth-bootstrap-guide.md) | source repaired: explicit admitted private JWT, scoped read-only counts; live diagnostic delivery not admitted by this repair |
|
||||
| `privacyidea/create-secrets.sh` | [README.md](../sso-mfa/k8s/privacyidea/README.md), [smooth-bootstrap-guide.md](../docs/smooth-bootstrap-guide.md) | unknown |
|
||||
| `privacyidea/enckey-bootstrap.sh` | [README.md](../sso-mfa/k8s/privacyidea/README.md) | unknown |
|
||||
| `privacyidea/reconcile-lldap-resolver-live.sh` | [keycape-exposure-resolver-reconciliation.md](../docs/keycape-exposure-resolver-reconciliation.md) | attempted by Bernd Worsch 2026-08-27; no completed PASS receipt |
|
||||
|
|
|
|||
|
|
@ -80,7 +80,7 @@ See T03 retrospective for past realm drift bumps (now partially automated via ru
|
|||
- Create platform-root user in LLDAP (via create-user.sh or LLDAP admin UI at lldap.coulomb.social).
|
||||
- Command example (with KUBECTL fallback): `cd sso-mfa/k8s/lldap && ./create-user.sh platform-root ...` (no --admin for non-root tests; use --admin only for platform admins via king path).
|
||||
- Self-enroll TOTP in privacyIDEA.
|
||||
- Verify MFA state: `cd ../privacyidea && ./check-user-mfa-state.sh platform-root`.
|
||||
- Verify MFA state: `bash sso-mfa/k8s/privacyidea/check-user-mfa-state.sh platform-root --token-file /absolute/private/diagnostic.jwt` after owner-admitted protected input. See the [diagnostic contract](../sso-mfa/k8s/privacyidea/README.md#scoped-factor-state-diagnostic); the KeyCape workload credential is not a general diagnostic delivery lane. Counts do not verify user existence or an MFA login.
|
||||
- Verify OIDC/KeyCape path: `cd ../keycape && ./verify-openbao-client.sh`.
|
||||
- Groups: net-kingdom-admins for platform-root.
|
||||
- Evidence: identity_account_created, identity_group_confirmed, mfa_*, oidc_login_verified.
|
||||
|
|
|
|||
|
|
@ -171,7 +171,13 @@ for u in users:
|
|||
echo ""
|
||||
echo "MFA / KeyCape readiness (using existing check scripts)..."
|
||||
cd ../privacyidea || true
|
||||
bash ./check-user-mfa-state.sh platform-root 2>&1 | tail -5 || true
|
||||
if [[ -n "${MFA_DIAGNOSTIC_TOKEN_FILE:-}" ]]; then
|
||||
if ! bash ./check-user-mfa-state.sh "$USERNAME" --token-file "$MFA_DIAGNOSTIC_TOKEN_FILE"; then
|
||||
echo "[UNVERIFIED] Factor-state diagnostic failed; no MFA readiness inferred."
|
||||
fi
|
||||
else
|
||||
echo "[UNVERIFIED] Factor-state diagnostic requires owner-admitted private input; see privacyIDEA README."
|
||||
fi
|
||||
cd ../keycape || true
|
||||
bash ./verify-openbao-client.sh 2>&1 | tail -3 || true
|
||||
|
||||
|
|
|
|||
|
|
@ -207,3 +207,47 @@ Secret and restarting cannot restore these files; the deployment does not mount
|
|||
the DR Secrets. The former unmounted helper-pod example is withdrawn. Native
|
||||
recovery acceptance requires the matching database backup, existing-token checks
|
||||
and audit-signature verification before closure.
|
||||
|
||||
## Scoped factor-state diagnostic
|
||||
|
||||
`check-user-mfa-state.sh` reports only factor counts in `coulomb`. It no longer
|
||||
reads the retired `keycape-pi-token` Secret, queries administrative realm/user
|
||||
catalogues, or guesses that the historic `netkingdom` realm is accessible.
|
||||
|
||||
```bash
|
||||
bash sso-mfa/k8s/privacyidea/check-user-mfa-state.sh platform-root \
|
||||
--token-file /absolute/private/diagnostic.jwt
|
||||
```
|
||||
|
||||
The file must contain an owner-admitted, unexpired provider admin-role JWT with
|
||||
`tokenlist` visibility in `coulomb`, optionally followed by one newline. It and
|
||||
its parent must be owned/private (normally 0600/0700), unlinked, and outside Git.
|
||||
The helper does not retrieve, mint, renew, export or cache credentials. Missing
|
||||
input stops before any credential or network access; raw values are never argv.
|
||||
|
||||
RPF-WP-0040's renewable factor-reader lane is delivered to **KeyCape**, not this
|
||||
workstation helper. That contract is not permission to extract its Secret or
|
||||
share its identity. An attended diagnostic needs its own explicitly admitted
|
||||
owner delivery/execution arrangement; this source change neither creates nor
|
||||
claims one. Use owner credential routing before requesting that arrangement.
|
||||
The historical resolver/admin-token lanes remain separately blocked.
|
||||
|
||||
The helper uses the canonical HTTPS provider with system CA verification, or an
|
||||
explicit literal loopback URL for a reviewed local forward/fixture. It refuses
|
||||
other origins, redirects and ambient proxies. HTTP loopback is only as trustworthy
|
||||
as the reviewed local process/forward. There is no insecure-TLS flag.
|
||||
|
||||
The existing disabled, unassigned `KCFACTORSCOPE01` token proves realm visibility
|
||||
before and after the lookup. Provider denial, missing/mismatched proof, malformed
|
||||
responses, unknown enrollment states or incomplete pages return `unverified`,
|
||||
never a successful zero. The diagnostic requests at most 1000 factors and refuses
|
||||
larger/incomplete results rather than publishing partial counts. The provider
|
||||
API is paginated; see the [upstream token API](https://privacyidea.readthedocs.io/en/v3.12.1/modules/api/token.html).
|
||||
|
||||
JSON output contains total, active-confirmed, active-pending and inactive counts.
|
||||
It deliberately records `user_existence: not_checked` and `mfa_login: not_tested`:
|
||||
a scoped zero-factor response does not prove that a user exists, and counts do
|
||||
not prove an MFA login. No token serial, metadata, provider error body or credential
|
||||
is emitted. Separate HTTP observations are not an atomic provider snapshot.
|
||||
Actual login remains `verify-t06.sh`/the supported user journey; pending enrollment
|
||||
must be confirmed before it counts as an active confirmed factor.
|
||||
|
|
|
|||
|
|
@ -1,89 +1,5 @@
|
|||
#!/usr/bin/env bash
|
||||
# check-user-mfa-state.sh — non-secret privacyIDEA MFA state diagnostic.
|
||||
#
|
||||
# Reports realm presence and user/token counts for a user without printing
|
||||
# passwords, OTP seeds, admin JWTs, token serials, or token metadata.
|
||||
#
|
||||
# Usage:
|
||||
# bash check-user-mfa-state.sh [username]
|
||||
|
||||
# Scoped read-only diagnostic; see README.md. No automatic credential retrieval.
|
||||
set +x
|
||||
set -euo pipefail
|
||||
|
||||
USERNAME="${1:-platform-root}"
|
||||
PI_URL="${PI_URL:-https://pink.coulomb.social}"
|
||||
SSO_NAMESPACE="${SSO_NAMESPACE:-sso}"
|
||||
KUBECTL="${KUBECTL:-kubectl}"
|
||||
|
||||
TOKEN="$(
|
||||
"$KUBECTL" get secret keycape-pi-token -n "$SSO_NAMESPACE" \
|
||||
-o jsonpath='{.data.token}' 2>/dev/null | base64 -d 2>/dev/null || true
|
||||
)"
|
||||
if [[ -z "$TOKEN" ]]; then
|
||||
TOKEN="$(
|
||||
"$KUBECTL" get secret keycape-pi-token -n "$SSO_NAMESPACE" \
|
||||
-o jsonpath='{.data.pi_admin_token}' 2>/dev/null | base64 -d 2>/dev/null || true
|
||||
)"
|
||||
fi
|
||||
|
||||
if [[ -z "$TOKEN" || "$TOKEN" == "PENDING_create-pi-token.sh" ]]; then
|
||||
echo "[FAIL] keycape-pi-token is missing or still a placeholder."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
api_get() {
|
||||
local path="$1"
|
||||
curl -sk -H "Authorization: $TOKEN" "$PI_URL$path"
|
||||
}
|
||||
|
||||
json_field() {
|
||||
python3 -c "$1"
|
||||
}
|
||||
|
||||
echo "privacyIDEA MFA state for user: $USERNAME"
|
||||
echo "Endpoint: $PI_URL"
|
||||
echo ""
|
||||
|
||||
REALM_RESP="$(api_get "/realm/")"
|
||||
echo "Realms:"
|
||||
printf '%s' "$REALM_RESP" | json_field '
|
||||
import json
|
||||
import sys
|
||||
|
||||
data = json.load(sys.stdin)
|
||||
if not data.get("result", {}).get("status", False):
|
||||
print(" [WARN] realm query returned status=false")
|
||||
err = data.get("result", {}).get("error", {})
|
||||
if err:
|
||||
print(" [WARN] error=" + str(err.get("message", err)))
|
||||
realms = data.get("result", {}).get("value", {})
|
||||
if not realms:
|
||||
print(" [WARN] no realms returned")
|
||||
for name in sorted(realms):
|
||||
print(" - " + name + " default=" + str(bool(realms[name].get("default"))))
|
||||
'
|
||||
|
||||
for realm in coulomb netkingdom; do
|
||||
echo ""
|
||||
echo "Realm: $realm"
|
||||
USER_RESP="$(api_get "/user/?realm=$realm&username=$USERNAME")"
|
||||
TOKEN_RESP="$(api_get "/token/?realm=$realm&user=$USERNAME")"
|
||||
|
||||
printf '%s' "$USER_RESP" | json_field '
|
||||
import json
|
||||
import sys
|
||||
|
||||
data = json.load(sys.stdin)
|
||||
users = data.get("result", {}).get("value", {}).get("users", [])
|
||||
print(f" users={len(users)}")
|
||||
'
|
||||
printf '%s' "$TOKEN_RESP" | json_field '
|
||||
import json
|
||||
import sys
|
||||
|
||||
data = json.load(sys.stdin)
|
||||
value = data.get("result", {}).get("value", {})
|
||||
tokens = value.get("tokens", []) if isinstance(value, dict) else []
|
||||
active = sum(1 for token in tokens if token.get("active", True))
|
||||
print(f" tokens={len(tokens)} active={active}")
|
||||
'
|
||||
done
|
||||
exec python3 "$(dirname -- "$0")/mfa_state.py" "$@"
|
||||
|
|
|
|||
204
sso-mfa/k8s/privacyidea/mfa_state.py
Normal file
204
sso-mfa/k8s/privacyidea/mfa_state.py
Normal file
|
|
@ -0,0 +1,204 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Read-only, scoped factor diagnostic. No credential discovery or issuance."""
|
||||
import argparse
|
||||
import base64
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import ssl
|
||||
import stat
|
||||
import sys
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
|
||||
REALM = 'coulomb'
|
||||
PROBE = 'KCFACTORSCOPE01'
|
||||
MAX_BODY = 1024 * 1024
|
||||
MAX_FACTORS = 1000
|
||||
|
||||
|
||||
class Refused(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class QuietParser(argparse.ArgumentParser):
|
||||
def error(self, message):
|
||||
raise Refused('arguments')
|
||||
|
||||
|
||||
class NoRedirect(urllib.request.HTTPRedirectHandler):
|
||||
def redirect_request(self, *args, **kwargs):
|
||||
return None
|
||||
|
||||
|
||||
def unique_object(pairs):
|
||||
result = {}
|
||||
for key, value in pairs:
|
||||
if key in result:
|
||||
raise Refused('response')
|
||||
result[key] = value
|
||||
return result
|
||||
|
||||
|
||||
def endpoint(value):
|
||||
if not isinstance(value, str) or not value.isascii() or any(ord(c) <= 32 for c in value):
|
||||
raise Refused('endpoint')
|
||||
parsed = urllib.parse.urlsplit(value)
|
||||
if parsed.username is not None or parsed.password is not None or parsed.query or parsed.fragment or parsed.path not in ('', '/'):
|
||||
raise Refused('endpoint')
|
||||
if value.rstrip('/') in ('https://pink.coulomb.social', 'https://pink.coulomb.social:443'):
|
||||
return value.rstrip('/')
|
||||
# Literal loopback only: an explicitly selected local fixture/approved forward.
|
||||
if (parsed.hostname not in ('127.0.0.1', '::1') or parsed.scheme not in ('http', 'https')
|
||||
or parsed.port is None or not 1 <= parsed.port <= 65535):
|
||||
raise Refused('endpoint')
|
||||
expected = f'{parsed.scheme}://{("[::1]" if parsed.hostname == "::1" else parsed.hostname)}:{parsed.port}'
|
||||
if value.rstrip('/') != expected:
|
||||
raise Refused('endpoint')
|
||||
return expected
|
||||
|
||||
|
||||
def credential(path):
|
||||
path = Path(os.path.abspath(path))
|
||||
if any(p.is_symlink() for p in (path, *path.parents)):
|
||||
raise Refused('credential')
|
||||
if any((p / '.git').is_file() or (p / '.git/HEAD').is_file() for p in path.parents):
|
||||
raise Refused('credential')
|
||||
parent = os.open(path.parent, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW)
|
||||
try:
|
||||
info = os.fstat(parent)
|
||||
if info.st_uid != os.getuid() or info.st_mode & 0o077:
|
||||
raise Refused('credential')
|
||||
fd = os.open(path.name, os.O_RDONLY | os.O_NONBLOCK | os.O_NOFOLLOW, dir_fd=parent)
|
||||
try:
|
||||
before = os.fstat(fd)
|
||||
if (not stat.S_ISREG(before.st_mode) or before.st_uid != os.getuid()
|
||||
or before.st_mode & 0o077 or before.st_nlink != 1 or before.st_size > 16385):
|
||||
raise Refused('credential')
|
||||
raw = os.read(fd, 16386)
|
||||
after = os.fstat(fd)
|
||||
if (before.st_size, before.st_mtime_ns, before.st_ctime_ns) != (after.st_size, after.st_mtime_ns, after.st_ctime_ns):
|
||||
raise Refused('credential')
|
||||
finally:
|
||||
os.close(fd)
|
||||
finally:
|
||||
os.close(parent)
|
||||
token = raw.decode('ascii').removesuffix('\n')
|
||||
if len(token) > 16384 or not re.fullmatch(r'[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+', token):
|
||||
raise Refused('credential')
|
||||
claims = json.loads(base64.urlsafe_b64decode(token.split('.')[1] + '==='), object_pairs_hook=unique_object)
|
||||
# A structural preflight only. The provider must verify signature and expiry.
|
||||
if (not isinstance(claims, dict) or claims.get('role') != 'admin'
|
||||
or type(claims.get('exp')) not in (int, float) or claims['exp'] <= time.time()):
|
||||
raise Refused('credential')
|
||||
return token
|
||||
|
||||
|
||||
class Client:
|
||||
def __init__(self, base, token):
|
||||
self.base = endpoint(base)
|
||||
self.token = token
|
||||
context = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
|
||||
# Use system trust, not ambient SSL_CERT_FILE/SSL_CERT_DIR overrides.
|
||||
context.load_verify_locations(cafile=ssl.get_default_verify_paths().openssl_cafile)
|
||||
self.opener = urllib.request.build_opener(urllib.request.ProxyHandler({}), NoRedirect(),
|
||||
urllib.request.HTTPSHandler(context=context))
|
||||
|
||||
def get(self, query):
|
||||
req = urllib.request.Request(self.base + '/token/?' + urllib.parse.urlencode(query),
|
||||
headers={'Authorization': self.token, 'Accept': 'application/json'}, method='GET')
|
||||
try:
|
||||
with self.opener.open(req, timeout=10) as response:
|
||||
if response.status != 200:
|
||||
raise Refused('provider')
|
||||
raw = response.read(MAX_BODY + 1)
|
||||
if len(raw) > MAX_BODY:
|
||||
raise Refused('response')
|
||||
document = json.loads(raw, object_pairs_hook=unique_object)
|
||||
result = document.get('result') if isinstance(document, dict) else None
|
||||
if not isinstance(result, dict) or result.get('status') is not True:
|
||||
raise Refused('provider')
|
||||
return result.get('value')
|
||||
except urllib.error.HTTPError as exc:
|
||||
exc.close()
|
||||
raise Refused('provider') from None
|
||||
except (urllib.error.URLError, OSError, ValueError):
|
||||
raise Refused('provider') from None
|
||||
|
||||
|
||||
def complete_page(value):
|
||||
if not isinstance(value, dict):
|
||||
raise Refused('response')
|
||||
count, rows = value.get('count'), value.get('tokens')
|
||||
if (type(count) is not int or not 0 <= count <= MAX_FACTORS or not isinstance(rows, list)
|
||||
or count != len(rows) or type(value.get('current')) is not int or value['current'] != 1
|
||||
or value.get('next') is not None or value.get('prev') is not None
|
||||
or any(not isinstance(row, dict) for row in rows)):
|
||||
raise Refused('response')
|
||||
serials = [row.get('serial') for row in rows]
|
||||
if any(not isinstance(s, str) or not s or len(s) > 256 for s in serials) or len(set(serials)) != len(rows):
|
||||
raise Refused('response')
|
||||
return rows
|
||||
|
||||
|
||||
def prove_scope(client):
|
||||
rows = complete_page(client.get({'serial': PROBE, 'tokenrealm': REALM, 'page': 1, 'pagesize': 2}))
|
||||
if (len(rows) != 1 or rows[0]['serial'] != PROBE or rows[0].get('active') is not False
|
||||
or rows[0].get('user_id') != '' or rows[0].get('realms') != [REALM]):
|
||||
raise Refused('visibility')
|
||||
|
||||
|
||||
def diagnose(client, username):
|
||||
# Avoid provider wildcard and user@realm expansion; support literal estate IDs.
|
||||
if not re.fullmatch(r'[A-Za-z0-9][A-Za-z0-9._-]{0,127}', username):
|
||||
raise Refused('username')
|
||||
prove_scope(client)
|
||||
rows = complete_page(client.get({'user': username, 'realm': REALM, 'tokenrealm': REALM,
|
||||
'page': 1, 'pagesize': MAX_FACTORS, 'sortby': 'serial', 'sortdir': 'asc'}))
|
||||
counts = {'total': len(rows), 'active_confirmed': 0, 'active_pending': 0, 'inactive': 0}
|
||||
for row in rows:
|
||||
if (row.get('username') != username or row.get('user_realm') != REALM
|
||||
or not isinstance(row.get('realms'), list) or REALM not in row['realms']
|
||||
or type(row.get('active')) is not bool):
|
||||
raise Refused('response')
|
||||
if not row['active']:
|
||||
counts['inactive'] += 1
|
||||
elif row.get('rollout_state') in ('', 'enrolled'):
|
||||
counts['active_confirmed'] += 1
|
||||
elif row.get('rollout_state') in ('verify', 'clientwait', 'pending'):
|
||||
counts['active_pending'] += 1
|
||||
else:
|
||||
raise Refused('response')
|
||||
# Do not turn a successful empty provider response after withdrawal into zero.
|
||||
prove_scope(client)
|
||||
return {'result': 'observed', 'realm': REALM, 'user': username, 'factors': counts,
|
||||
'reader_visibility': 'verified', 'user_existence': 'not_checked', 'mfa_login': 'not_tested'}
|
||||
|
||||
|
||||
def main():
|
||||
phase = 'input'
|
||||
try:
|
||||
parser = QuietParser(description=__doc__)
|
||||
parser.add_argument('username', nargs='?', default='platform-root')
|
||||
parser.add_argument('--token-file', required=True, help='Owner-admitted private JWT file outside Git; no automatic retrieval')
|
||||
parser.add_argument('--pi-url', default=os.environ.get('PI_URL', 'https://pink.coulomb.social'))
|
||||
args = parser.parse_args()
|
||||
base = endpoint(args.pi_url)
|
||||
if not re.fullmatch(r'[A-Za-z0-9][A-Za-z0-9._-]{0,127}', args.username):
|
||||
raise Refused('username')
|
||||
token = credential(args.token_file)
|
||||
phase = 'diagnostic'
|
||||
result = diagnose(Client(base, token), args.username)
|
||||
print(json.dumps(result, sort_keys=True))
|
||||
return 0
|
||||
except Exception:
|
||||
# Never render exception text, URLs, paths, raw provider errors or tokens.
|
||||
print(json.dumps({'result': 'unverified', 'phase': phase}), file=sys.stderr)
|
||||
return 1
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
raise SystemExit(main())
|
||||
123
tests/mfa_state_native_fixture.py
Normal file
123
tests/mfa_state_native_fixture.py
Normal file
|
|
@ -0,0 +1,123 @@
|
|||
"""Disposable real-provider acceptance, invoked in a separate Python process."""
|
||||
import contextlib
|
||||
import importlib.util
|
||||
import io
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
from pathlib import Path
|
||||
import sys
|
||||
import tempfile
|
||||
import threading
|
||||
import time
|
||||
|
||||
|
||||
def run():
|
||||
for name in list(os.environ):
|
||||
if name.startswith('PRIVACYIDEA_'): del os.environ[name]
|
||||
logging.disable(logging.CRITICAL)
|
||||
root_repo = Path(__file__).resolve().parents[1]
|
||||
spec = importlib.util.spec_from_file_location('mfa_state', root_repo/'sso-mfa/k8s/privacyidea/mfa_state.py')
|
||||
m = importlib.util.module_from_spec(spec); spec.loader.exec_module(m)
|
||||
from privacyidea.app import create_app
|
||||
from privacyidea.models import db
|
||||
from privacyidea.lib.policy import set_policy, enable_policy
|
||||
from privacyidea.lib.resolver import save_resolver
|
||||
from privacyidea.lib.realm import set_realm
|
||||
from privacyidea.lib.auth import create_db_admin
|
||||
from privacyidea.lib.resolvers.PasswdIdResolver import crypt_ctx
|
||||
from privacyidea.lib.token import init_token
|
||||
from privacyidea.lib.user import User
|
||||
from werkzeug.serving import make_server
|
||||
result = {}
|
||||
with tempfile.TemporaryDirectory(prefix='mfa-native-') as directory:
|
||||
root = Path(directory); (root/'enckey').write_bytes(os.urandom(96))
|
||||
cfg = root/'fixture.cfg'
|
||||
config = dict(SQLALCHEMY_DATABASE_URI='sqlite:///'+str(root/'db.sqlite'),
|
||||
SECRET_KEY='disposable-native-fixture', PI_PEPPER='disposable-native-pepper',
|
||||
PI_NO_RESPONSE_SIGN=True, PI_AUDIT_NO_SIGN=True, PI_LOGFILE=str(root/'log'),
|
||||
PI_ENCFILE=str(root/'enckey'), PI_TRUSTED_JWT=[],
|
||||
PI_AUDIT_SQL_URI='sqlite:///'+str(root/'audit.sqlite'))
|
||||
cfg.write_text('\n'.join(k+'='+repr(v) for k,v in config.items()))
|
||||
app = create_app(config_name='testing', config_file=str(cfg), silent=True)
|
||||
with app.app_context():
|
||||
assert str(db.engine.url) == config['SQLALCHEMY_DATABASE_URI']; db.create_all()
|
||||
from privacyidea.lib.audit import getAudit
|
||||
from privacyidea.lib.auditmodules.sqlaudit import LogEntry
|
||||
LogEntry.__table__.create(getAudit(app.config).engine, checkfirst=True)
|
||||
passwd = root/'users'
|
||||
password = crypt_ctx.hash('fixture-user-password', scheme='sha512_crypt')
|
||||
passwd.write_text('alice:'+password+':1001:1001:Fixture:/tmp:/bin/false\nbob:'+password+':1002:1002:Fixture:/tmp:/bin/false\n')
|
||||
save_resolver({'resolver':'fixture-users','type':'passwdresolver','fileName':str(passwd)})
|
||||
set_realm('coulomb',[{'name':'fixture-users'}])
|
||||
create_db_admin('fixture-reader', password='fixture-reader-password')
|
||||
set_policy(name='fixture-baseline',scope='admin',action='*',adminuser=['*','!fixture-reader'])
|
||||
set_policy(name='fixture-reader',scope='admin',action='tokenlist',adminuser='fixture-reader',realm='coulomb')
|
||||
probe = init_token({'serial':m.PROBE,'type':'hotp','genkey':1,'realm':'coulomb'})
|
||||
probe.token.active = False; probe.token.save()
|
||||
for i in range(18):
|
||||
init_token({'serial':'FIXTURE'+str(i),'type':'hotp','genkey':1},user=User('alice','coulomb'))
|
||||
pending = init_token({'serial':'FIXTUREPENDING','type':'hotp','genkey':1},user=User('alice','coulomb'))
|
||||
pending.token.rollout_state = 'verify'; pending.token.save()
|
||||
inactive = init_token({'serial':'FIXTUREINACTIVE','type':'hotp','genkey':1},user=User('alice','coulomb'))
|
||||
inactive.token.active = False; inactive.token.save()
|
||||
native = app.test_client()
|
||||
def login(user, password, realm=None):
|
||||
args={'username':user,'password':password}
|
||||
if realm: args['realm']=realm
|
||||
response=native.post('/auth',json=args)
|
||||
assert response.status_code==200 and response.get_json()['result']['status']
|
||||
return response.get_json()['result']['value']['token']
|
||||
token=login('fixture-reader','fixture-reader-password')
|
||||
tokenfile=root/'token';tokenfile.write_text(token);tokenfile.chmod(0o600)
|
||||
assert m.credential(tokenfile)==token
|
||||
server=make_server('127.0.0.1',0,app); thread=threading.Thread(target=server.serve_forever,daemon=True);thread.start()
|
||||
try:
|
||||
client=m.Client(f'http://127.0.0.1:{server.server_port}',token)
|
||||
counts=m.diagnose(client,'alice')['factors']
|
||||
assert counts=={'total':20,'active_confirmed':18,'active_pending':1,'inactive':1},counts
|
||||
result['native_counts_beyond_default_page']=True
|
||||
assert m.diagnose(client,'bob')['factors']['total']==0
|
||||
result['native_empty_with_visibility']=True
|
||||
def refused(fn):
|
||||
try: fn()
|
||||
except m.Refused: return True
|
||||
return False
|
||||
with app.app_context(): enable_policy('fixture-reader',False)
|
||||
raw=native.get('/token/?user=alice&realm=coulomb',headers={'Authorization':token})
|
||||
assert raw.status_code==200 and raw.get_json()['result']['value']['count']==0
|
||||
assert refused(lambda:m.diagnose(client,'alice'))
|
||||
result['native_withdrawal_empty_success_refused']=True
|
||||
with app.app_context(): enable_policy('fixture-reader',True)
|
||||
assert m.diagnose(client,'alice')['factors']['total']==20
|
||||
result['native_permission_recovery']=True
|
||||
usertoken=login('alice','fixture-user-password','coulomb')
|
||||
assert refused(lambda:m.diagnose(m.Client(client.base,usertoken),'bob'))
|
||||
result['native_self_service_cannot_prove_other_user_visibility']=True
|
||||
assert refused(lambda:m.diagnose(m.Client(client.base,token[:-5]+'XXXXX'),'alice'))
|
||||
result['native_tampered_signature_refused']=True
|
||||
with app.app_context():
|
||||
set_policy(name='fixture-short-session',scope='webui',action='jwt_validity=2')
|
||||
short=login('fixture-reader','fixture-reader-password')
|
||||
import base64
|
||||
claims=json.loads(base64.urlsafe_b64decode(short.split('.')[1]+'==='))
|
||||
remaining=claims['exp']-time.time();assert 0 < remaining < 3
|
||||
time.sleep(remaining+0.2)
|
||||
assert refused(lambda:m.diagnose(m.Client(client.base,short),'alice'))
|
||||
result['native_expired_provider_jwt_refused']=True
|
||||
finally:
|
||||
server.shutdown();thread.join();server.server_close()
|
||||
return result
|
||||
|
||||
|
||||
if __name__=='__main__':
|
||||
try:
|
||||
with contextlib.redirect_stdout(io.StringIO()), contextlib.redirect_stderr(io.StringIO()):
|
||||
result=run()
|
||||
print(json.dumps(result,sort_keys=True))
|
||||
except Exception as exc:
|
||||
# Fixture-only, sanitized: no database/HTTP body/credential traceback.
|
||||
import traceback
|
||||
frame = traceback.extract_tb(exc.__traceback__)[-1]
|
||||
print(json.dumps({'result':'failed','type':type(exc).__name__, 'file':Path(frame.filename).name, 'line':frame.lineno, 'counts':exc.args[0] if exc.args and isinstance(exc.args[0],dict) and set(exc.args[0])=={'total','active_confirmed','active_pending','inactive'} else None}))
|
||||
raise SystemExit(1)
|
||||
233
tests/test_mfa_state_diagnostic.py
Normal file
233
tests/test_mfa_state_diagnostic.py
Normal file
|
|
@ -0,0 +1,233 @@
|
|||
"""Diagnostic regressions: private inputs, actual HTTP transport, fail-closed counts."""
|
||||
import base64
|
||||
import copy
|
||||
import importlib.util
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
from urllib.parse import parse_qs, urlsplit
|
||||
|
||||
import pytest
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
HELPERS = ROOT / 'sso-mfa/k8s/privacyidea'
|
||||
spec = importlib.util.spec_from_file_location('mfa_state', HELPERS / 'mfa_state.py')
|
||||
m = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(m)
|
||||
|
||||
|
||||
def jwt(**changes):
|
||||
claims = {'role': 'admin', 'exp': time.time() + 300, **changes}
|
||||
encoded = base64.urlsafe_b64encode(json.dumps(claims).encode()).rstrip(b'=').decode()
|
||||
return 'eyJhbGciOiJIUzI1NiJ9.' + encoded + '.syntheticSignature'
|
||||
|
||||
|
||||
def page(rows):
|
||||
return {'tokens': rows, 'count': len(rows), 'current': 1, 'next': None, 'prev': None}
|
||||
|
||||
|
||||
def factor(serial='synthetic-factor', **changes):
|
||||
return {'serial': serial, 'active': True, 'user_id': '1001', 'username': 'alice',
|
||||
'user_realm': 'coulomb', 'realms': ['coulomb'], 'rollout_state': 'enrolled', **changes}
|
||||
|
||||
|
||||
def probe(**changes):
|
||||
return factor(m.PROBE, active=False, user_id='', username='', **changes)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def server():
|
||||
state = {'user': page([factor()]), 'proof': page([probe()]), 'requests': [], 'calls': 0}
|
||||
class Handler(BaseHTTPRequestHandler):
|
||||
def log_message(self, *_): pass
|
||||
def do_GET(self):
|
||||
state['requests'].append((self.path, dict(self.headers)))
|
||||
state['calls'] += 1
|
||||
if state.get('status'):
|
||||
self.send_response(state['status'])
|
||||
self.send_header('Location', state.get('location', '/stolen'))
|
||||
self.end_headers(); self.wfile.write(b'private-provider-message'); return
|
||||
query = parse_qs(urlsplit(self.path).query)
|
||||
value = state['proof'] if 'serial' in query else state['user']
|
||||
if state.get('withdraw_after_user') and state['calls'] >= 3: value = page([])
|
||||
body = json.dumps({'result': {'status': True, 'value': value}}).encode()
|
||||
self.send_response(200); self.end_headers(); self.wfile.write(state.get('raw', body))
|
||||
s = ThreadingHTTPServer(('127.0.0.1', 0), Handler)
|
||||
t = threading.Thread(target=s.serve_forever, daemon=True); t.start()
|
||||
try: yield f'http://127.0.0.1:{s.server_port}', state
|
||||
finally: s.shutdown(); s.server_close(); t.join()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def token_file(tmp_path):
|
||||
private = tmp_path / 'private'; private.mkdir(mode=0o700)
|
||||
path = private / 'input'; path.write_text(jwt() + '\n'); path.chmod(0o600)
|
||||
return path
|
||||
|
||||
|
||||
def invoke(token_file, url, *extra):
|
||||
return subprocess.run(['bash', '-x', str(HELPERS / 'check-user-mfa-state.sh'), 'alice',
|
||||
'--token-file', str(token_file), '--pi-url', url, *extra],
|
||||
capture_output=True, text=True, timeout=15)
|
||||
|
||||
|
||||
def test_real_http_cli_reports_complete_private_counts(server, token_file, monkeypatch):
|
||||
url, state = server
|
||||
state['user'] = page([factor(str(i)) for i in range(18)] +
|
||||
[factor('pending', rollout_state='verify'), factor('inactive', active=False)])
|
||||
monkeypatch.setenv('http_proxy', 'http://127.0.0.1:1'); monkeypatch.setenv('HTTP_PROXY', 'http://127.0.0.1:1')
|
||||
monkeypatch.setenv('no_proxy', ''); monkeypatch.setenv('NO_PROXY', '')
|
||||
p = invoke(token_file, url)
|
||||
assert p.returncode == 0, p.stderr
|
||||
data = json.loads(p.stdout)
|
||||
assert data['factors'] == {'total': 20, 'active_confirmed': 18, 'active_pending': 1, 'inactive': 1}
|
||||
assert data['user_existence'] == 'not_checked' and data['mfa_login'] == 'not_tested'
|
||||
assert len(state['requests']) == 3
|
||||
for path, headers in state['requests']:
|
||||
assert urlsplit(path).path == '/token/' and 'Content-Type' not in headers
|
||||
assert headers['Authorization'] == token_file.read_text().strip()
|
||||
assert token_file.read_text().strip() not in p.stdout + p.stderr
|
||||
assert str(token_file) not in p.stdout + p.stderr
|
||||
assert 'synthetic-factor' not in p.stdout
|
||||
|
||||
|
||||
@pytest.mark.parametrize('mutation', ['empty', 'assigned', 'active', 'realm', 'serial', 'count', 'withdraw'])
|
||||
def test_scope_uncertainty_never_reports_zero(server, mutation):
|
||||
url, state = server; state['user'] = page([])
|
||||
if mutation == 'empty': state['proof'] = page([])
|
||||
elif mutation == 'assigned': state['proof']['tokens'][0]['user_id'] = 'someone'
|
||||
elif mutation == 'active': state['proof']['tokens'][0]['active'] = True
|
||||
elif mutation == 'realm': state['proof']['tokens'][0]['realms'] = ['other']
|
||||
elif mutation == 'serial': state['proof']['tokens'][0]['serial'] = 'other'
|
||||
elif mutation == 'count': state['proof']['count'] = 2
|
||||
elif mutation == 'withdraw': state['withdraw_after_user'] = True
|
||||
with pytest.raises(m.Refused): m.diagnose(m.Client(url, jwt()), 'alice')
|
||||
|
||||
|
||||
def test_empty_verified_result_is_not_proof_user_exists(server):
|
||||
url, state = server; state['user'] = page([])
|
||||
data = m.diagnose(m.Client(url, jwt()), 'alice')
|
||||
assert data['factors']['total'] == 0 and data['user_existence'] == 'not_checked'
|
||||
|
||||
|
||||
@pytest.mark.parametrize('mutation', ['truncated', 'duplicate', 'next', 'count-bool', 'current-bool', 'wrong-user', 'wrong-realm', 'missing-active', 'missing-state', 'unknown-state'])
|
||||
def test_malformed_or_incomplete_user_result_refused(server, mutation):
|
||||
url, state = server; value = state['user']; row = value['tokens'][0]
|
||||
if mutation == 'truncated': value['count'] = 20
|
||||
elif mutation == 'duplicate': value['tokens'].append(copy.deepcopy(row)); value['count'] = 2
|
||||
elif mutation == 'next': value['next'] = 2
|
||||
elif mutation == 'count-bool': value['count'] = True
|
||||
elif mutation == 'current-bool': value['current'] = True
|
||||
elif mutation == 'wrong-user': row['username'] = 'bob'
|
||||
elif mutation == 'wrong-realm': row['user_realm'] = 'other'
|
||||
elif mutation == 'missing-active': row.pop('active')
|
||||
elif mutation == 'missing-state': row.pop('rollout_state')
|
||||
elif mutation == 'unknown-state': row['rollout_state'] = 'new-unreviewed-state'
|
||||
with pytest.raises(m.Refused): m.diagnose(m.Client(url, jwt()), 'alice')
|
||||
|
||||
|
||||
@pytest.mark.parametrize('mode', ['public', 'symlink', 'hardlink', 'fifo', 'directory', 'public-parent', 'bad-jwt', 'self-service', 'expired', 'oversized', 'inside-git'])
|
||||
def test_bad_input_precedes_any_network(server, token_file, mode):
|
||||
url, state = server
|
||||
if mode == 'public': token_file.chmod(0o644)
|
||||
elif mode == 'symlink': token_file.rename(token_file.with_suffix('.real')); token_file.symlink_to(token_file.with_suffix('.real'))
|
||||
elif mode == 'hardlink': os.link(token_file, token_file.with_suffix('.link'))
|
||||
elif mode == 'fifo': token_file.unlink(); os.mkfifo(token_file, 0o600)
|
||||
elif mode == 'directory': token_file.unlink(); token_file.mkdir(mode=0o700)
|
||||
elif mode == 'public-parent': token_file.parent.chmod(0o755)
|
||||
elif mode == 'bad-jwt': token_file.write_text('private-secret-not-a-jwt')
|
||||
elif mode == 'self-service': token_file.write_text(jwt(role='user'))
|
||||
elif mode == 'expired': token_file.write_text(jwt(exp=1))
|
||||
elif mode == 'oversized': token_file.write_bytes(b'x' * 16386)
|
||||
elif mode == 'inside-git': (token_file.parent / '.git').mkdir(); (token_file.parent / '.git/HEAD').write_text('ref: refs/heads/main')
|
||||
p = invoke(token_file, url)
|
||||
assert p.returncode == 1 and not p.stdout and 'unverified' in p.stderr
|
||||
assert 'private-secret' not in p.stderr and not state['requests']
|
||||
|
||||
|
||||
@pytest.mark.parametrize('url', ['https://attacker.invalid', 'http://pink.coulomb.social', 'http://localhost:8000',
|
||||
'http://127.0.0.1:8000/path', 'http://127.0.0.1:8000?x=1',
|
||||
'https://user:password@pink.coulomb.social', 'http://127.1:8000'])
|
||||
def test_destination_is_checked_before_credential(url, tmp_path):
|
||||
p = invoke(tmp_path/'absent', url)
|
||||
assert p.returncode == 1 and not p.stdout and 'password' not in p.stderr
|
||||
|
||||
|
||||
@pytest.mark.parametrize('status', [301, 302, 307, 308, 401, 403, 500])
|
||||
def test_no_redirect_or_provider_error_leak(server, token_file, status):
|
||||
url, state = server; state['status'] = status
|
||||
p = invoke(token_file, url)
|
||||
assert p.returncode == 1 and not p.stdout
|
||||
assert len(state['requests']) == 1 and 'private-provider-message' not in p.stderr
|
||||
|
||||
|
||||
@pytest.mark.parametrize('raw', [b'{"result":{"status":false,"error":{"message":"private-provider-message"}}}',
|
||||
b'{"result":{"status":true,"status":false}}', b'not-json', b'x'*(m.MAX_BODY+1)], ids=['error', 'duplicate', 'invalid', 'oversized'])
|
||||
def test_bad_provider_body_is_sanitized(server, token_file, raw):
|
||||
url, state = server; state['raw'] = raw
|
||||
p = invoke(token_file, url)
|
||||
assert p.returncode == 1 and not p.stdout and 'private-provider-message' not in p.stderr
|
||||
|
||||
|
||||
def test_unknown_argument_is_not_echoed(server, token_file):
|
||||
url, state = server
|
||||
p = invoke(token_file, url, '--unknown=private-argument')
|
||||
assert p.returncode == 1 and 'private-argument' not in p.stderr and not state['requests']
|
||||
|
||||
|
||||
@pytest.mark.parametrize('username', ['alice&realm=other', '*', 'alice@other', '../alice'])
|
||||
def test_provider_expansion_is_refused_before_request(server, username):
|
||||
url, state = server
|
||||
with pytest.raises(m.Refused): m.diagnose(m.Client(url, jwt()), username)
|
||||
assert not state['requests']
|
||||
|
||||
|
||||
def test_real_privacyidea_contract():
|
||||
pytest.importorskip('privacyidea')
|
||||
p = subprocess.run([sys.executable, str(ROOT/'tests/mfa_state_native_fixture.py')],
|
||||
capture_output=True, text=True, timeout=180)
|
||||
assert p.returncode == 0, p.stdout
|
||||
data = json.loads(p.stdout)
|
||||
assert len(data) == 7 and all(v is True for v in data.values())
|
||||
|
||||
|
||||
def test_untrusted_https_is_not_accepted_via_ambient_ca(tmp_path, token_file, monkeypatch):
|
||||
import datetime
|
||||
import ipaddress
|
||||
import ssl
|
||||
from cryptography import x509
|
||||
from cryptography.hazmat.primitives import hashes, serialization
|
||||
from cryptography.hazmat.primitives.asymmetric import rsa
|
||||
from cryptography.x509.oid import NameOID
|
||||
key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
|
||||
name = x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, 'fixture')])
|
||||
now = datetime.datetime.now(datetime.timezone.utc)
|
||||
cert = (x509.CertificateBuilder().subject_name(name).issuer_name(name).public_key(key.public_key())
|
||||
.serial_number(x509.random_serial_number()).not_valid_before(now-datetime.timedelta(minutes=1))
|
||||
.not_valid_after(now+datetime.timedelta(hours=1))
|
||||
.add_extension(x509.SubjectAlternativeName([x509.IPAddress(ipaddress.ip_address('127.0.0.1'))]), False)
|
||||
.sign(key, hashes.SHA256()))
|
||||
cert_path = tmp_path/'cert.pem'; key_path = tmp_path/'key.pem'
|
||||
cert_path.write_bytes(cert.public_bytes(serialization.Encoding.PEM))
|
||||
key_path.write_bytes(key.private_bytes(serialization.Encoding.PEM, serialization.PrivateFormat.PKCS8, serialization.NoEncryption()))
|
||||
key_path.chmod(0o600)
|
||||
seen = []
|
||||
class Handler(BaseHTTPRequestHandler):
|
||||
def log_message(self, *_): pass
|
||||
def do_GET(self): seen.append(dict(self.headers)); self.send_error(500)
|
||||
server = ThreadingHTTPServer(('127.0.0.1', 0), Handler)
|
||||
context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER); context.load_cert_chain(cert_path, key_path)
|
||||
server.socket = context.wrap_socket(server.socket, server_side=True)
|
||||
thread = threading.Thread(target=server.serve_forever, daemon=True); thread.start()
|
||||
monkeypatch.setenv('SSL_CERT_FILE', str(cert_path))
|
||||
try:
|
||||
p = invoke(token_file, f'https://127.0.0.1:{server.server_port}')
|
||||
assert p.returncode == 1 and not p.stdout and not seen
|
||||
assert token_file.read_text().strip() not in p.stderr
|
||||
finally:
|
||||
server.shutdown(); server.server_close(); thread.join()
|
||||
|
|
@ -200,10 +200,10 @@ Remaining closure checklist (source review, not proof of current live use):
|
|||
repaired below, including private no-overwrite export and a real PVC recovery
|
||||
transport. Actual off-host backup lineage, reviewed image/cluster execution and
|
||||
native service/database/token/audit recovery acceptance remain T03 requirements.
|
||||
- `privacyidea/check-user-mfa-state.sh`, linked by the smooth-bootstrap guide,
|
||||
still reads retired `keycape-pi-token`, disables TLS verification, puts its token
|
||||
in curl argv and prints provider error text. Reconcile with the current scoped
|
||||
factor-reader owner contract and a bounded diagnostic interface.
|
||||
- `privacyidea/check-user-mfa-state.sh` and its guide/dry-run callers are repaired
|
||||
below: explicit private input, scoped counts and reader-visibility proof replace
|
||||
retired Secret retrieval. Live diagnostic delivery/execution still requires
|
||||
separate owner admission; KeyCape's workload lane is not silently shared.
|
||||
- `lldap/bootstrap-users.sh` still sources credentials, sends password/token in
|
||||
argv and can print auth responses. Inspect its group/user provisioning callers
|
||||
against the current User Engine owner path before replacing or retiring them.
|
||||
|
|
@ -253,6 +253,21 @@ No production key, PVC, workload or Secret was read or changed for these tests.
|
|||
T02 remains progress for the remaining diagnostic/LDAP/realm callers and full
|
||||
inventory/lifecycle reconciliation. T03 remains the native recovery gate.
|
||||
|
||||
2026-10-13 scoped factor diagnostic repair: replaced retired admin-token Secret
|
||||
retrieval with `mfa_state.py` and explicit private JWT input. The read-only helper
|
||||
uses only `/token/` in `coulomb`, checks the existing disabled/unassigned realm
|
||||
probe before and after lookup, refuses incomplete pages, and distinguishes
|
||||
active-confirmed, active-pending and inactive counts. Empty results are not proof
|
||||
of user existence or login. TLS verification, origin restriction, redirect/proxy
|
||||
refusal, bounded responses and fixed errors prevent the original credential
|
||||
argv/provider-error output paths. No automatic retrieval, issuance or permission
|
||||
expansion is introduced. Guide and non-root dry-run now require explicit admitted
|
||||
input; the latter checks its selected user rather than hard-coded platform-root.
|
||||
Source/native disposable verification and limits are recorded in Custodian
|
||||
`docs/evidence/2026-10-13-netkingdom-security/mfa-state-diagnostic.json`.
|
||||
KeyCape-only RPF-WP-0040 delivery is not treated as authorization for this helper.
|
||||
LDAP/realm callers, full inventory and T03 native recovery remain open.
|
||||
|
||||
## Verify owner-approved recovery and current native callers
|
||||
|
||||
```task
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue