Implement scoped P06 authentication policy and guarded optional onboarding
All checks were successful
Authentication acceptance / acceptance (push) Successful in 1m22s
Authentication acceptance / provider-contract (push) Successful in 14s
Build and Publish Container Image / build-and-push (push) Successful in 41s

Assistant: codex
Assistant-Model: gpt-6-astra
Assistant-Session: 01a09cbb-87c6-7900-a145-4ce53ba9f1a6
This commit is contained in:
tegwick 2026-09-14 00:00:05 +02:00
parent aa709fb854
commit e0b3c25f06
12 changed files with 1085 additions and 2 deletions

View file

@ -0,0 +1,27 @@
"""Provider enrollment hook: password-only self-service cannot replace an active factor.
Loaded explicitly by provider configuration, so a missing module fails startup.
Pending setup can be regenerated/confirmed; active-factor replacement goes through
fresh-MFA audited recovery. Admin actions retain their existing provider policies.
"""
def check(request, action):
from flask import g
from privacyidea.lib.error import PolicyError
from privacyidea.lib.user import User
from privacyidea.lib.token import get_tokens
principal = g.logged_in_user
if principal.get('role') != 'user':
return True
user = User(principal.get('username', ''), principal.get('realm', ''))
if user.is_empty() or action != 'init':
raise PolicyError('Use the account recovery process to replace an active authenticator.')
tokens = get_tokens(user=user, active=True)
if any(token.token.rollout_state not in {'verify', 'clientwait', 'pending'} for token in tokens):
raise PolicyError('An active authenticator already exists. Use the account recovery process to replace it.')
serial = request.all_data.get('serial')
if serial:
matches = [token for token in tokens if token.token.serial == serial]
if len(matches) != 1 or matches[0].token.rollout_state != 'verify':
raise PolicyError('Only your unfinished authenticator setup can be confirmed or regenerated.')
return True

View file

@ -0,0 +1,53 @@
"""Isolated provider contract test. Never loads production config or credentials."""
import contextlib,io,json,logging,os,tempfile,time,base64,hmac,hashlib,struct,urllib.parse
from pathlib import Path
result={"success":False};phase="isolation"
def check(name,condition):
result[name]=bool(condition)
if not condition:raise ValueError(name)
def run():
global phase
for k in list(os.environ):
if k.startswith("PRIVACYIDEA_"):del os.environ[k]
logging.disable(logging.CRITICAL)
with tempfile.TemporaryDirectory(prefix="provider-contract-") as d:
root=Path(d);(root/'enckey').write_bytes(os.urandom(96))
cfg=root/'fixture.cfg';cfg.write_text("SQLALCHEMY_DATABASE_URI='sqlite:///PLACEHOLDER_DB'\nSECRET_KEY='isolated-fixture-only'\nPI_PEPPER='isolated-fixture-only'\nPI_NO_RESPONSE_SIGN=True\nPI_AUDIT_NO_SIGN=True\nPI_LOGFILE="+repr(str(root/'log'))+"\nPI_ENCFILE="+repr(str(root/'enckey'))+"\nPI_TRUSTED_JWT=[]\n")
cfg.write_text(cfg.read_text().replace('PLACEHOLDER_DB', str(root/'fixture.sqlite')))
with cfg.open('a') as f:f.write('PI_AUDIT_SQL_URI='+repr('sqlite:///'+str(root/'audit.sqlite'))+'\n')
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
app=create_app(config_name="testing",config_file=str(cfg),silent=True)
app.config["PI_INIT_CHECK_HOOK"]="keycape_onboarding_guard.check"
phase="fixture_database"
with app.app_context():
check('database_isolated',str(db.engine.url)=='sqlite:///'+str(root/'fixture.sqlite'));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';passwd.write_text('alice:'+crypt_ctx.hash('fixture-password',scheme='sha512_crypt')+':1001:1001:Fixture:/tmp:/bin/false\n')
with passwd.open('a') as f:f.write('native-alice:'+crypt_ctx.hash('fixture-password',scheme='sha512_crypt')+':1002:1002:Native fixture:/tmp:/bin/false\n')
save_resolver({'resolver':'fixture-users','type':'passwdresolver','fileName':str(passwd)})
set_realm('fixture',[{'name':'fixture-users'}])
create_db_admin('fixture-reader',password='fixture-service-password')
set_policy(name='fixture-admin-baseline',scope='admin',action='*',adminuser=['*','!fixture-reader'])
set_policy(name='fixture-reader',scope='admin',action='tokenlist',adminuser='fixture-reader',realm='fixture')
set_policy(name='fixture-user',scope='user',action='enrollTOTP',realm='fixture')
set_policy(name='fixture-pending-cancel',scope='user',action='delete',realm='fixture',conditions=[('token','rollout_state','equals','verify',True)])
set_policy(name='fixture-confirmation',scope='enrollment',action='verify_enrollment=totp',realm='fixture')
set_policy(name='fixture-passthru',scope='authentication',action='passthru',realm='fixture')
from privacyidea.lib.token import init_token
probe=init_token({'serial':'P06SCOPEPROBE','type':'hotp','genkey':1,'realm':'fixture'})
probe.token.active=False;probe.token.save()
print(json.dumps({"fixture_ready":True,"production_database":False}),flush=True)
app.run(host="0.0.0.0",port=8088,debug=False,use_reloader=False)
if __name__=="__main__":
try:run()
except Exception as error:
print(json.dumps({"fixture_failure":type(error).__name__,"phase":phase}),flush=True)
raise SystemExit(1)

View file

@ -22,6 +22,7 @@ def run():
from privacyidea.lib.auth import create_db_admin
from privacyidea.lib.resolvers.PasswdIdResolver import crypt_ctx
app=create_app(config_name="testing",config_file=str(cfg),silent=True)
app.config["PI_INIT_CHECK_HOOK"]="keycape_onboarding_guard.check"
phase="fixture_database"
with app.app_context():
check('database_isolated',str(db.engine.url)=='sqlite:///:memory:');db.create_all()
@ -34,7 +35,8 @@ def run():
create_db_admin('fixture-reader',password='fixture-service-password')
set_policy(name='fixture-admin-baseline',scope='admin',action='*',adminuser=['*','!fixture-reader'])
set_policy(name='fixture-reader',scope='admin',action='tokenlist',adminuser='fixture-reader',realm='fixture')
set_policy(name='fixture-user',scope='user',action='enrollTOTP,delete,disable',realm='fixture')
set_policy(name='fixture-user',scope='user',action='enrollTOTP',realm='fixture')
set_policy(name='fixture-pending-cancel',scope='user',action='delete',realm='fixture',conditions=[('token','rollout_state','equals','verify',True)])
set_policy(name='fixture-confirmation',scope='enrollment',action='verify_enrollment=totp',realm='fixture')
set_policy(name='fixture-passthru',scope='authentication',action='passthru',realm='fixture')
client=app.test_client()
@ -74,6 +76,14 @@ def run():
check('possession_confirmed',code==200 and body['result']['status'] and body['result']['value'] is True)
code,body=req('GET','/token/?user=alice&realm=fixture&active=True',token=reader)
check('confirmed_token_enrolled',code==200 and body['result']['value']['tokens'][0]['rollout_state']=='enrolled')
code,body=req('DELETE','/token/'+serial,token=user)
check('pre_enrollment_session_cannot_delete_confirmed_factor',code in (400,403))
code,body=req('POST','/token/disable',{'serial':serial},user)
check('self_service_cannot_disable_confirmed_factor',code in (400,403))
code,body=req('POST','/token/init',{'type':'totp','genkey':'1'},user)
check('pre_enrollment_session_cannot_add_another_factor',code in (400,403))
code,body=req('POST','/token/init',{'serial':serial,'type':'totp','genkey':'1'},user)
check('pre_enrollment_session_cannot_regenerate_active_factor',code in (400,403))
phase='provider_scope_withdrawal'
with app.app_context():enable_policy('fixture-reader',False)
code,body=req('GET','/token/?tokenrealm=fixture',token=reader)

View file

@ -0,0 +1,68 @@
import {createHmac} from 'node:crypto';
// No production accounts, network providers or header overrides are used.
const [debug, base] = process.argv.slice(2);
const version=await (await fetch(debug+'/json/version')).json();
const ws=new WebSocket(version.webSocketDebuggerUrl);
await new Promise(resolve=>ws.addEventListener('open',resolve,{once:true}));
let next=0;const pending=new Map();
ws.addEventListener('message',event=>{const m=JSON.parse(event.data);if(pending.has(m.id)){const p=pending.get(m.id);pending.delete(m.id);m.error?p.reject(Error(m.error.message)):p.resolve(m.result);}});
const call=(method,params={},sessionId)=>new Promise((resolve,reject)=>{const id=++next;pending.set(id,{resolve,reject});ws.send(JSON.stringify({id,method,params,...(sessionId?{sessionId}:{})}));});
const {targetId}=await call('Target.createTarget',{url:'about:blank'});
const {sessionId}=await call('Target.attachToTarget',{targetId,flatten:true});
const cmd=(method,params)=>call(method,params,sessionId);
await cmd('Page.enable');await cmd('Network.enable');
const evaluate=async expression=>{const r=await cmd('Runtime.evaluate',{expression,returnByValue:true});if(r.exceptionDetails)throw Error(r.exceptionDetails.exception?.description?.split('\n')[0] || 'Browser evaluation failed');return r.result.value;};
async function waitFor(expression){for(let n=0;n<300;n++){if(await evaluate(expression))return;await new Promise(r=>setTimeout(r,100));}throw Error('Browser condition not met: '+expression);}
async function navigate(path){await cmd('Page.navigate',{url:base+path});await waitFor('location.origin === '+JSON.stringify(base)+' && document.readyState !== "loading"');}
async function identity(who){await cmd('Network.clearBrowserCookies');if(who)await cmd('Network.setCookie',{name:'ue_session',value:who,url:base,path:'/',httpOnly:true,sameSite:'Lax'});}
let checks=0;async function check(expression,name){if(!await evaluate(expression))throw Error(name);console.log('PASS '+name);checks++;}
const captures=[];const responses=new Set();
ws.addEventListener('message', async event=>{const message=JSON.parse(event.data);if(message.method==='Network.responseReceived' && message.params.response.url.split('?')[0].endsWith('/token/init'))responses.add(message.params.requestId);if(message.method==='Network.loadingFinished' && responses.has(message.params.requestId)){try{const body=await cmd('Network.getResponseBody',{requestId:message.params.requestId});captures.push(JSON.parse(body.body));}catch{}}});
try {
await navigate('/');
await waitFor('!!document.querySelector("#username") && document.querySelector("#username").offsetParent!==null');
await evaluate(`document.querySelector('#username').value='alice@fixture';document.querySelector('#username').dispatchEvent(new Event('input',{bubbles:true}));const password=Array.from(document.querySelectorAll('input[type=password]')).find(e=>e.offsetParent!==null);password.value='fixture-password';password.dispatchEvent(new Event('input',{bubbles:true}));Array.from(document.querySelectorAll('button')).find(b=>b.offsetParent!==null && b.textContent.trim()==='Log In').click()`);
await waitFor('!!Array.from(document.links).find(a=>a.textContent.trim()==="Enroll Token")');
await check(`document.body.innerText.includes('alice @fixture (user)')`,'provider password login identifies the correct account');
async function enroll(){
const count=captures.length;
await evaluate(`location.hash='!/token/enroll///'`);
await waitFor('!!document.querySelector("#tokentype") && document.querySelector("#tokentype").offsetParent!==null');
await waitFor('document.querySelector("#tokentype").value === "string:totp"');
await evaluate(`document.querySelector('#tokentype').dispatchEvent(new Event('change',{bubbles:true}));`);
await evaluate(`Array.from(document.querySelectorAll('button')).find(b=>b.offsetParent!==null && b.textContent.trim()==='Enroll Token').click()`);
for(let i=0;i<300 && captures.length===count;i++)await new Promise(r=>setTimeout(r,100));
const response=captures[count];if(response?.detail?.rollout_state!=='verify')throw Error('Enrollment did not require possession confirmation');
await waitFor('!!document.querySelector("#verifyResponse") && document.querySelector("#verifyResponse").offsetParent!==null');
return response.detail;
}
const first=await enroll();
await check(`!!document.querySelector('#verifyResponse')`,'provider pending setup requests possession proof');
await evaluate(`location.hash='!/token/list'`);
await waitFor('!!Array.from(document.links).find(a=>a.textContent.trim()==='+JSON.stringify(first.serial)+')');
await evaluate(`Array.from(document.links).find(a=>a.textContent.trim()===${JSON.stringify(first.serial)}).click()`);
await waitFor(`!!document.querySelector('[ng-click="deleteTokenAsk()"]')`);
await waitFor(`!document.querySelector('#deleteButton').disabled && Array.from(document.querySelectorAll('td')).some(e=>e.textContent.trim()==='verify')`);
await evaluate(`document.querySelector('[ng-click="deleteTokenAsk()"]').click()`);
await waitFor('location.hash.includes("/token/list") && !Array.from(document.links).find(a=>a.textContent.trim()==='+JSON.stringify(first.serial)+')');
await check(`!document.body.innerText.includes(${JSON.stringify(first.serial)})`,'provider unfinished setup can be cancelled');
const second=await enroll();
const uri=new URL(second.googleurl.value);const alphabet='ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';let bits=0,value=0;const bytes=[];
for(const character of uri.searchParams.get('secret').replace(/=/g,'')){value=(value<<5)|alphabet.indexOf(character);bits+=5;if(bits>=8){bits-=8;bytes.push((value>>>bits)&255);}}
const counter=Buffer.alloc(8);counter.writeBigUInt64BE(BigInt(Math.floor(Date.now()/30000)));
const digest=createHmac('sha1',Buffer.from(bytes)).update(counter).digest();const offset=digest[digest.length-1]&15;const otp=String((digest.readUInt32BE(offset)&0x7fffffff)%1000000).padStart(6,'0');
await evaluate(`const field=document.querySelector('#verifyResponse');field.value=${JSON.stringify(otp)};field.dispatchEvent(new Event('input',{bubbles:true}));Array.from(document.querySelectorAll('button')).find(b=>b.offsetParent!==null && b.textContent.trim()==='Verify Token').click()`);
await waitFor('document.body.innerText.includes("Token successfully verified")');
await check(`document.body.innerText.includes('Token successfully verified')`,'provider browser possession confirmation activates TOTP');
// A password-only provider session must not enroll an attacker-controlled replacement.
await evaluate(`location.hash='!/token/list'`);
await waitFor('!!Array.from(document.links).find(a=>a.textContent.trim()==='+JSON.stringify(second.serial)+')');
await evaluate(`location.hash='!/token/enroll///'`);
await waitFor('!!document.querySelector("#tokentype") && document.querySelector("#tokentype").offsetParent!==null');
await waitFor('document.querySelector("#tokentype").value === "string:totp"');
await evaluate(`document.querySelector('#tokentype').dispatchEvent(new Event('change',{bubbles:true}));`);
await evaluate(`Array.from(document.querySelectorAll('button')).find(b=>b.offsetParent!==null && b.textContent.trim()==='Enroll Token').click()`);
await waitFor('document.body.innerText.includes("active authenticator already exists")');
await check(`document.body.innerText.includes('account recovery process')`,'provider active-factor replacement has a recovery route');
console.log(JSON.stringify({checks,result:'passed',scope:'installed provider browser, disposable database and fixture identity only'}));
} finally {await call('Target.closeTarget',{targetId});ws.close();}

View file

@ -0,0 +1,95 @@
#!/usr/bin/env python3
"""Run installed-provider browser and KeyCape adapter acceptance in a disposable Job.
No production environment, service account, databases or real users are mounted.
Requires kubectl, Chromium, Node and Go; prints checks, never OTP material.
"""
from pathlib import Path
import json
import os
import shutil
import socket
import subprocess
import tempfile
import time
import urllib.request
import uuid
ROOT=Path(__file__).resolve().parents[1]
IMAGE='ghcr.io/gpappsoft/privacyidea-docker@sha256:af7841adad262f129e0c1d4f553af13f21cb2f4dc713533f316cfe43ed0b4473'
def main():
chrome=os.environ.get('JOURNEY_CHROME') or shutil.which('chromium') or shutil.which('google-chrome')
if not chrome:
candidates=sorted((Path.home()/'.cache/ms-playwright').glob('chromium-*/chrome-linux64/chrome'))
chrome=str(candidates[-1]) if candidates else None
if not chrome or not shutil.which('node') or not shutil.which('go'):
raise RuntimeError('Chromium, Node and Go are required; acceptance was not run')
guard=(ROOT/'scripts/keycape_onboarding_guard.py').read_text()
source='import sys,types\ng=types.ModuleType("keycape_onboarding_guard");sys.modules["keycape_onboarding_guard"]=g\nexec('+repr(guard)+',g.__dict__)\n'+(ROOT/'scripts/provider-browser-fixture.py').read_text()
name='provider-p06-browser-'+uuid.uuid4().hex[:8]
job={'apiVersion':'batch/v1','kind':'Job','metadata':{'name':name,'namespace':'mfa'},'spec':{'activeDeadlineSeconds':600,'ttlSecondsAfterFinished':3600,'template':{'metadata':{'labels':{'app.kubernetes.io/name':'provider-p06-browser'}},'spec':{'automountServiceAccountToken':False,'restartPolicy':'Never','securityContext':{'runAsUser':65534,'runAsGroup':65534,'runAsNonRoot':True,'fsGroup':65534},'containers':[{'name':'fixture','image':IMAGE,'command':['python3','-c',source],'env':[{'name':'PYTHONDONTWRITEBYTECODE','value':'1'}],'securityContext':{'allowPrivilegeEscalation':False,'readOnlyRootFilesystem':True,'capabilities':{'drop':['ALL']}},'resources':{'requests':{'cpu':'1m','memory':'128Mi'},'limits':{'cpu':'500m','memory':'384Mi'}},'volumeMounts':[{'name':'scratch','mountPath':'/tmp'}]}],'volumes':[{'name':'scratch','emptyDir':{}}]}}}}
created=subprocess.run(['kubectl','create','-f','-'],input=json.dumps(job).encode(),capture_output=True)
if created.returncode:raise RuntimeError('Fixture creation failed')
forward=None
forward_log=tempfile.TemporaryFile(mode="w+t")
try:
for _ in range(120):
result=subprocess.run(['kubectl','-n','mfa','get','pods','-l','job-name='+name,'-o','json'],capture_output=True)
pods=json.loads(result.stdout).get('items',[]) if result.returncode==0 else []
if pods and pods[0]['status']['phase']=='Running':break
time.sleep(.5)
else:raise RuntimeError('Fixture did not start')
# A Running pod is not an HTTP-ready provider. Connecting too early can
# terminate kubectl port-forward on the first refused upstream socket.
for _ in range(120):
logs=subprocess.run(['kubectl','-n','mfa','logs','job/'+name],capture_output=True,text=True)
events=[]
for line in logs.stdout.splitlines():
try: events.append(json.loads(line))
except ValueError: pass
if any(event.get('fixture_ready') is True for event in events if isinstance(event,dict)):break
if any('fixture_failure' in event for event in events if isinstance(event,dict)):raise RuntimeError('Fixture initialization failed')
time.sleep(.5)
else:raise RuntimeError('Fixture initialization timed out')
with socket.socket() as listener:listener.bind(('127.0.0.1',0));port=listener.getsockname()[1]
forward=subprocess.Popen(['kubectl','-n','mfa','port-forward','job/'+name,str(port)+':8088','--address=127.0.0.1'],stdout=subprocess.DEVNULL,stderr=forward_log)
base='http://127.0.0.1:'+str(port)
for _ in range(120):
if forward.poll() is not None:raise RuntimeError('Fixture forwarding stopped')
try:
with urllib.request.urlopen(base+'/',timeout=2) as response:
if response.status==200:break
except Exception:time.sleep(.25)
else:raise RuntimeError('Fixture HTTP unavailable')
environment=dict(os.environ,P06_NATIVE_PROVIDER_URL=base)
subprocess.run(['go','test','./internal/server/oidc','-run','^TestNativeOptionalEnrollmentAndOldSession$','-count=1','-v'],cwd=ROOT/'src',env=environment,check=True,timeout=120)
with tempfile.TemporaryDirectory(prefix='p06-provider-browser-') as profile:
browser=subprocess.Popen([chrome,'--headless','--no-sandbox','--disable-gpu','--remote-debugging-address=127.0.0.1','--remote-debugging-port=0','--user-data-dir='+profile,'about:blank'],stdout=subprocess.DEVNULL,stderr=subprocess.DEVNULL)
try:
marker=Path(profile)/'DevToolsActivePort'
for _ in range(100):
if marker.exists():break
time.sleep(.1)
debug='http://127.0.0.1:'+marker.read_text().splitlines()[0]
subprocess.run(['node',str(ROOT/'scripts/provider_browser.mjs'),debug,base],check=True,timeout=240)
finally:browser.terminate();browser.wait(timeout=5)
print(json.dumps({'success':True,'job':name,'provider_image':IMAGE,'production_accounts_changed':False}))
except Exception:
if forward and forward.poll() is not None:
forward_log.seek(0)
message=forward_log.read(1000)
print(json.dumps({"forward_exit":forward.returncode,"reason": "pod_not_running" if "not running" in message else "permission" if "permission" in message.lower() else "port_in_use" if "address already in use" in message else "forward_failed"}),flush=True)
logs=subprocess.run(['kubectl','-n','mfa','logs','job/'+name],capture_output=True,text=True)
for line in logs.stdout.splitlines():
try:
event=json.loads(line)
if 'fixture_failure' in event:print(json.dumps({k:event[k] for k in ['fixture_failure','phase']}),flush=True)
except Exception:pass
raise
finally:
if forward:forward.terminate();forward.wait(timeout=5)
forward_log.close()
subprocess.run(['kubectl','-n','mfa','delete','job',name,'--wait=false'],stdout=subprocess.DEVNULL,stderr=subprocess.DEVNULL)
if __name__=='__main__':main()