diff --git a/.forgejo/workflows/acceptance.yaml b/.forgejo/workflows/acceptance.yaml index c1ff7a7..b1bcdc2 100644 --- a/.forgejo/workflows/acceptance.yaml +++ b/.forgejo/workflows/acceptance.yaml @@ -2,7 +2,7 @@ name: Authentication acceptance on: push: branches: [main] - paths: ["src/**", "scripts/provider-onboarding-contract.py", "scripts/factor_recovery.py", "scripts/test_factor_recovery.py", "scripts/recovery_service.py", "scripts/test_recovery_service.py", ".forgejo/workflows/acceptance.yaml"] + paths: ["src/**", "scripts/provider-onboarding-contract.py", "scripts/keycape_onboarding_guard.py", "scripts/factor_recovery.py", "scripts/test_factor_recovery.py", "scripts/recovery_service.py", "scripts/test_recovery_service.py", ".forgejo/workflows/acceptance.yaml"] workflow_dispatch: jobs: acceptance: diff --git a/scripts/keycape_onboarding_guard.py b/scripts/keycape_onboarding_guard.py new file mode 100644 index 0000000..2bc28a2 --- /dev/null +++ b/scripts/keycape_onboarding_guard.py @@ -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 diff --git a/scripts/provider-browser-fixture.py b/scripts/provider-browser-fixture.py new file mode 100644 index 0000000..4778576 --- /dev/null +++ b/scripts/provider-browser-fixture.py @@ -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) diff --git a/scripts/provider-onboarding-contract.py b/scripts/provider-onboarding-contract.py index cdfeaee..5ac86bf 100644 --- a/scripts/provider-onboarding-contract.py +++ b/scripts/provider-onboarding-contract.py @@ -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) diff --git a/scripts/provider_browser.mjs b/scripts/provider_browser.mjs new file mode 100644 index 0000000..2abdf8d --- /dev/null +++ b/scripts/provider_browser.mjs @@ -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();} diff --git a/scripts/provider_browser_acceptance.py b/scripts/provider_browser_acceptance.py new file mode 100644 index 0000000..bc9f597 --- /dev/null +++ b/scripts/provider_browser_acceptance.py @@ -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() diff --git a/src/cmd/keycape/main.go b/src/cmd/keycape/main.go index 5f3b0c7..d0b102d 100644 --- a/src/cmd/keycape/main.go +++ b/src/cmd/keycape/main.go @@ -30,6 +30,7 @@ import ( "keycape/internal/domain" servererrors "keycape/internal/server/errors" "keycape/internal/server/oidc" + "keycape/internal/server/policy" "keycape/internal/server/telemetry" ) @@ -172,6 +173,15 @@ func main() { Issuer: issuer, Emitter: emitter, } + if path := os.Getenv("KEYCAPE_POLICY_PATH"); path != "" { + policies, err := policy.Open(path, clients) + if err != nil { + log.Error().Msg("authentication policy store unavailable") + os.Exit(1) + } + authorizeHandler.EffectivePolicy = policies.Effective + mux.Handle("/platform/authentication-policy", policy.Handler(policies, issuer, &privateKey.PublicKey)) + } mux.Handle("/authorize", enforcement.Middleware(authorizeHandler)) mux.Handle("/authorize/callback", authorizeHandler) mux.Handle("/authorize/return", authorizeHandler) diff --git a/src/internal/server/oidc/authorize.go b/src/internal/server/oidc/authorize.go index b3abc06..b07772e 100644 --- a/src/internal/server/oidc/authorize.go +++ b/src/internal/server/oidc/authorize.go @@ -66,6 +66,7 @@ func (p *pendingStateStore) Delete(state string) { // AuthorizeHandler implements GET /authorize and GET /authorize/callback. type AuthorizeHandler struct { + EffectivePolicy func(*domain.Client) (*domain.Client, error) AccountPortalURL string ClientConfig map[string]*domain.Client Auth domain.AuthProvider @@ -372,6 +373,13 @@ func (h *AuthorizeHandler) ServeHTTPCallback(w http.ResponseWriter, r *http.Requ func (h *AuthorizeHandler) decideAssurance(ctx context.Context, ps *PendingState, username string, login *LoginSession) (domain.AssuranceDecision, error) { client := h.ClientConfig[ps.ClientID] + if h.EffectivePolicy != nil { + var err error + client, err = h.EffectivePolicy(client) + if err != nil { + return domain.AssuranceDecision{}, err + } + } providerRequired := false if !domain.ACRRequiresAAL2(ps.ACRValues) && (client == nil || client.MFARequired == nil) { var err error diff --git a/src/internal/server/oidc/native_optional_test.go b/src/internal/server/oidc/native_optional_test.go new file mode 100644 index 0000000..47d4df2 --- /dev/null +++ b/src/internal/server/oidc/native_optional_test.go @@ -0,0 +1,124 @@ +package oidc_test + +import ( + "context" + "crypto/hmac" + "crypto/sha1" + "encoding/base32" + "encoding/binary" + "encoding/json" + "fmt" + "keycape/internal/adapters/privacyidea" + "keycape/internal/domain" + "keycape/internal/server/oidc" + "net/http" + "net/http/httptest" + "net/url" + "os" + "strings" + "testing" + "time" +) + +// Uses only the companion disposable provider fixture, never a production URL. +func TestNativeOptionalEnrollmentAndOldSession(t *testing.T) { + base := os.Getenv("P06_NATIVE_PROVIDER_URL") + if base == "" { + t.Skip("requires disposable installed-provider fixture") + } + parsed, e := url.Parse(base) + if e != nil || parsed.Hostname() != "127.0.0.1" || parsed.Scheme != "http" { + t.Fatal("loopback fixture required") + } + call := func(method, path, token string, data url.Values) map[string]interface{} { + r, e := http.NewRequest(method, base+path, strings.NewReader(data.Encode())) + if e != nil { + t.Fatal("fixture request") + } + r.Header.Set("Content-Type", "application/x-www-form-urlencoded") + if token != "" { + r.Header.Set("Authorization", token) + } + client := &http.Client{Timeout: 10 * time.Second} + response, e := client.Do(r) + if e != nil { + t.Fatal("fixture unavailable") + } + defer response.Body.Close() + var body map[string]interface{} + if json.NewDecoder(response.Body).Decode(&body) != nil || response.StatusCode != 200 { + t.Fatal("fixture request failed", response.StatusCode) + } + return body + } + value := func(b map[string]interface{}) map[string]interface{} { + return b["result"].(map[string]interface{})["value"].(map[string]interface{}) + } + reader := value(call("POST", "/auth", "", url.Values{"username": {"fixture-reader"}, "password": {"fixture-service-password"}}))["token"].(string) + user := value(call("POST", "/auth", "", url.Values{"username": {"native-alice"}, "password": {"fixture-password"}, "realm": {"fixture"}}))["token"].(string) + adapter := privacyidea.New(privacyidea.Config{BaseURL: base, Realm: "fixture", AdminToken: reader, ReadProbeSerial: "P06SCOPEPROBE", RequireForAll: true}, nil) + h := &oidc.AuthorizeHandler{ClientConfig: map[string]*domain.Client{"fixture": {ClientID: "fixture", MFAOptional: true}}, Auth: &mockAuthProvider{callbackResult: &domain.AuthResult{Username: "native-alice"}}, MFA: adapter, Sessions: oidc.NewSessionStore(), Logins: oidc.NewLoginSessionStore(), Emitter: &captureEmitter{}} + callback := func(acr []string, cookie *http.Cookie) *httptest.ResponseRecorder { + h.PendingStates().Store("native", &oidc.PendingState{ClientID: "fixture", RedirectURI: "https://fixture.test/callback", State: "native", ACRValues: acr, ExpiresAt: time.Now().Add(time.Minute)}) + r := httptest.NewRequest("GET", "/authorize/callback?code=fixture&state=native", nil) + if cookie != nil { + r.AddCookie(cookie) + } + w := httptest.NewRecorder() + h.ServeHTTPCallback(w, r) + return w + } + first := callback(nil, nil) + if first.Code != 302 { + t.Fatal("native no-factor decision", first.Code) + } + cookie := first.Result().Cookies()[0] + detail := call("POST", "/token/init", user, url.Values{"type": {"totp"}, "genkey": {"1"}})["detail"].(map[string]interface{}) + if detail["rollout_state"] != "verify" { + t.Fatal("possession confirmation not required") + } + if w := callback(nil, cookie); w.Code != 302 { + t.Fatal("pending setup activated MFA") + } + uri, e := url.Parse(detail["googleurl"].(map[string]interface{})["value"].(string)) + if e != nil { + t.Fatal("invalid fixture enrollment URI") + } + key, e := base32.StdEncoding.WithPadding(base32.NoPadding).DecodeString(strings.TrimRight(uri.Query().Get("secret"), "=")) + if e != nil { + t.Fatal("fixture seed format") + } + otp := func() string { + counter := make([]byte, 8) + binary.BigEndian.PutUint64(counter, uint64(time.Now().Unix()/30)) + mac := hmac.New(sha1.New, key) + mac.Write(counter) + digest := mac.Sum(nil) + offset := digest[len(digest)-1] & 15 + return fmt.Sprintf("%06d", (binary.BigEndian.Uint32(digest[offset:offset+4])&0x7fffffff)%1000000) + } + call("POST", "/token/init", user, url.Values{"serial": {detail["serial"].(string)}, "type": {"totp"}, "verify": {otp()}}) + if enrolled, e := adapter.HasEnrolledFactor(context.Background(), "native-alice"); e != nil || !enrolled { + t.Fatal("native activation not observed") + } + if w := callback(nil, cookie); w.Code != 200 || !strings.Contains(w.Body.String(), "KeyCape MFA") { + t.Fatal("old AAL1 session bypassed native enrolled factor") + } + if w := callback([]string{"aal2"}, cookie); w.Code != 200 || !strings.Contains(w.Body.String(), "KeyCape MFA") { + t.Fatal("native explicit step-up bypassed") + } + // Confirmation consumed the current TOTP; wait for the next independent code. + time.Sleep(time.Duration(31-time.Now().Unix()%30) * time.Second) + request := httptest.NewRequest("POST", "/authorize/callback", strings.NewReader(url.Values{"state": {"native"}, "mfa_token": {otp()}}.Encode())) + request.Header.Set("Content-Type", "application/x-www-form-urlencoded") + response := httptest.NewRecorder() + h.ServeHTTPCallback(response, request) + if response.Code != 302 { + t.Fatal("native OTP sign-in failed", response.Code) + } + location, _ := url.Parse(response.Header().Get("Location")) + session, ok := h.Sessions.Get(location.Query().Get("code")) + if !ok || !session.MFAVerified { + t.Fatal("native OTP did not establish MFA") + } +} diff --git a/src/internal/server/oidc/policy_runtime_test.go b/src/internal/server/oidc/policy_runtime_test.go new file mode 100644 index 0000000..c70d6a4 --- /dev/null +++ b/src/internal/server/oidc/policy_runtime_test.go @@ -0,0 +1,69 @@ +package oidc_test + +import ( + "errors" + "keycape/internal/domain" + "keycape/internal/server/oidc" + "keycape/internal/server/policy" + "net/http" + "net/http/httptest" + "path/filepath" + "strings" + "testing" + "time" +) + +func TestRuntimePolicyEnrollmentOldSessionAndStepUp(t *testing.T) { + clients := map[string]*domain.Client{} + for _, id := range []string{"vergabe-demo-company", "user-engine-portal"} { + clients[id] = &domain.Client{ClientID: id, GrantTypes: []string{"authorization_code"}} + } + policies, e := policy.Open(filepath.Join(t.TempDir(), "policy.json"), clients) + if e != nil { + t.Fatal(e) + } + defer policies.Close() + mfa := &mockMFAProvider{required: true} + h := &oidc.AuthorizeHandler{ClientConfig: clients, EffectivePolicy: policies.Effective, Auth: &mockAuthProvider{callbackResult: &domain.AuthResult{Username: "alice"}}, MFA: mfa, Sessions: oidc.NewSessionStore(), Logins: oidc.NewLoginSessionStore(), Emitter: &captureEmitter{}} + callback := func(acr []string, cookie *http.Cookie) *httptest.ResponseRecorder { + h.PendingStates().Store("runtime", &oidc.PendingState{ClientID: "vergabe-demo-company", RedirectURI: "https://app.test/callback", State: "runtime", ACRValues: acr, ExpiresAt: time.Now().Add(time.Minute)}) + r := httptest.NewRequest("GET", "/authorize/callback?code=fixture&state=runtime", nil) + if cookie != nil { + r.AddCookie(cookie) + } + w := httptest.NewRecorder() + h.ServeHTTPCallback(w, r) + return w + } + if w := callback(nil, nil); w.Code != 200 || !strings.Contains(w.Body.String(), "KeyCape MFA") { + t.Fatal("mandatory policy not effective") + } + preview, e := policies.Operation("operator", policy.Request{Action: "preview", Client: "vergabe-demo-company", Mode: policy.Optional, Reference: "change"}) + if e != nil { + t.Fatal(e) + } + if _, e = policies.Operation("operator", policy.Request{Action: "apply", Confirmation: preview["confirmation"].(string), Acknowledged: true}); e != nil { + t.Fatal(e) + } + first := callback(nil, nil) + if first.Code != 302 { + t.Fatal("no-factor login denied", first.Code) + } + cookie := first.Result().Cookies()[0] + mfa.enrolled = true + if w := callback(nil, cookie); w.Code != 200 || !strings.Contains(w.Body.String(), "KeyCape MFA") { + t.Fatal("old AAL1 session bypassed enrolled factor") + } + mfa.enrolled = false + if w := callback([]string{"aal2"}, cookie); w.Code != 200 || !strings.Contains(w.Body.String(), "KeyCape MFA") { + t.Fatal("optional policy bypassed explicit MFA") + } + mfa.enrolledErr = errors.New("fixture outage") + if w := callback(nil, cookie); w.Code != 500 || strings.Contains(w.Header().Get("Location"), "code=") { + t.Fatal("lookup outage granted authorization") + } + mfa.enrolledErr = nil + if w := callback(nil, cookie); w.Code != 302 { + t.Fatal("lookup recovery failed") + } +} diff --git a/src/internal/server/policy/policy.go b/src/internal/server/policy/policy.go new file mode 100644 index 0000000..5955ce6 --- /dev/null +++ b/src/internal/server/policy/policy.go @@ -0,0 +1,423 @@ +// Package policy owns the narrowly scoped browser MFA policy and durable audit. +package policy + +import ( + "crypto/rand" + "crypto/rsa" + "encoding/hex" + "encoding/json" + "errors" + "io" + "net/http" + "os" + "path/filepath" + "strings" + "sync" + "syscall" + "time" + + "keycape/internal/domain" + "keycape/internal/jose" +) + +const Optional = "optional_after_enrollment" +const Mandatory = "mandatory" + +var ErrUnavailable = errors.New("policy_unavailable") + +type Receipt struct { + Reference string `json:"reference"` + Actor string `json:"actor"` + Client string `json:"client"` + Before string `json:"before"` + After string `json:"after"` + Revision uint64 `json:"revision"` + At int64 `json:"at"` +} +type state struct { + Revision uint64 `json:"revision"` + Modes map[string]string `json:"modes"` + History []Receipt `json:"history"` +} +type ticket struct { + Actor, Client, Mode, Reference string + Revision uint64 + Expires int64 +} +type Store struct { + lock *os.File + mu sync.Mutex + path string + current state + names map[string]string + pending map[string]ticket + failed bool + now func() time.Time +} + +func Open(path string, clients map[string]*domain.Client) (*Store, error) { + s := &Store{path: path, names: map[string]string{}, pending: map[string]ticket{}, now: time.Now} + s.current.Modes = map[string]string{} + for _, id := range []string{"vergabe-demo-company", "user-engine-portal"} { + c := clients[id] + if c == nil || !contains(c.GrantTypes, "authorization_code") || contains(c.GrantTypes, "client_credentials") { + return nil, ErrUnavailable + } + mode := Mandatory + if c.MFAOptional && c.MFARequired == nil { + mode = Optional + } else if c.MFARequired != nil && !*c.MFARequired { + return nil, ErrUnavailable + } + s.current.Modes[id] = mode + s.names[id] = c.DisplayName + } + lock, err := os.OpenFile(path+".lock", os.O_CREATE|os.O_RDWR, 0600) + if err != nil { + return nil, ErrUnavailable + } + if syscall.Flock(int(lock.Fd()), syscall.LOCK_EX|syscall.LOCK_NB) != nil { + lock.Close() + return nil, ErrUnavailable + } + s.lock = lock + initialized := false + defer func() { + if !initialized { + s.Close() + } + }() + raw, err := os.ReadFile(path) + if err != nil && !os.IsNotExist(err) { + return nil, ErrUnavailable + } + if err == nil { + if len(raw) > 16*1024*1024 || json.Unmarshal(raw, &s.current) != nil || len(s.current.Modes) != 2 { + return nil, ErrUnavailable + } + for id, mode := range s.current.Modes { + if _, ok := s.names[id]; !ok || !validMode(mode) { + return nil, ErrUnavailable + } + } + if len(s.current.History) > 0 && s.current.History[len(s.current.History)-1].Revision != s.current.Revision { + return nil, ErrUnavailable + } + } else if s.persist(s.current) != nil { + return nil, ErrUnavailable + } + initialized = true + return s, nil +} +func (s *Store) Close() { + if s.lock != nil { + syscall.Flock(int(s.lock.Fd()), syscall.LOCK_UN) + s.lock.Close() + s.lock = nil + } +} +func contains(values []string, value string) bool { + for _, v := range values { + if v == value { + return true + } + } + return false +} +func validMode(mode string) bool { return mode == Optional || mode == Mandatory } +func (s *Store) persist(next state) error { + raw, err := json.Marshal(next) + if err != nil || len(raw) > 16*1024*1024 { + return ErrUnavailable + } + file, err := os.CreateTemp(filepath.Dir(s.path), ".policy-") + if err != nil { + return ErrUnavailable + } + name := file.Name() + defer os.Remove(name) + if _, err = file.Write(raw); err == nil { + err = file.Sync() + } + closeErr := file.Close() + if err != nil || closeErr != nil { + return ErrUnavailable + } + if os.Rename(name, s.path) != nil { + return ErrUnavailable + } + dir, err := os.Open(filepath.Dir(s.path)) + if err != nil { + s.failed = true + return ErrUnavailable + } + defer dir.Close() + if dir.Sync() != nil { + s.failed = true + return ErrUnavailable + } + return nil +} + +// Effective returns a copy; unrelated registrations and explicit AAL2 stay intact. +func (s *Store) Effective(c *domain.Client) (*domain.Client, error) { + if c == nil { + return c, nil + } + s.mu.Lock() + defer s.mu.Unlock() + mode, ok := s.current.Modes[c.ClientID] + if !ok { + return c, nil + } + if s.failed { + return nil, ErrUnavailable + } + copy := *c + copy.MFAOptional = mode == Optional + copy.MFARequired = nil + if mode == Mandatory { + required := true + copy.MFARequired = &required + } + return ©, nil +} + +type Request struct { + Action string `json:"action"` + Client string `json:"client"` + Mode string `json:"mode"` + Reference string `json:"reference"` + Confirmation string `json:"confirmation"` + Acknowledged bool `json:"acknowledged"` +} + +func (s *Store) snapshot() map[string]interface{} { + clients := []map[string]interface{}{} + for _, id := range []string{"vergabe-demo-company", "user-engine-portal"} { + clients = append(clients, map[string]interface{}{"id": id, "name": s.names[id], "mode": s.current.Modes[id]}) + } + history := s.current.History + if len(history) > 20 { + history = history[len(history)-20:] + } + return map[string]interface{}{"success": true, "status": "current", "revision": s.current.Revision, "clients": clients, "history": history} +} +func (s *Store) Operation(actor string, request Request) (map[string]interface{}, error) { + s.mu.Lock() + defer s.mu.Unlock() + if s.failed { + return nil, ErrUnavailable + } + if request.Action == "status" { + return s.snapshot(), nil + } + if request.Action == "preview" || request.Action == "rollback" { + if _, ok := s.names[request.Client]; !ok || !validReference(request.Reference) { + return nil, errors.New("unsupported_policy") + } + mode := request.Mode + if request.Action == "rollback" { + mode = "" + for i := len(s.current.History) - 1; i >= 0; i-- { + r := s.current.History[i] + if r.Client == request.Client { + mode = r.Before + break + } + } + } + if !validMode(mode) { + return nil, errors.New("unsupported_policy") + } + if mode == s.current.Modes[request.Client] { + return nil, errors.New("policy_unchanged") + } + for _, r := range s.current.History { + if r.Reference == request.Reference { + return nil, errors.New("reference_already_used") + } + } + for key, t := range s.pending { + if t.Expires < s.now().Unix() { + delete(s.pending, key) + } + } + if len(s.pending) >= 1024 { + return nil, ErrUnavailable + } + bytes := make([]byte, 32) + if _, err := rand.Read(bytes); err != nil { + return nil, ErrUnavailable + } + key := hex.EncodeToString(bytes) + s.pending[key] = ticket{actor, request.Client, mode, request.Reference, s.current.Revision, s.now().Unix() + 900} + result := s.snapshot() + result["status"] = "preview" + result["confirmation"] = key + result["client"] = request.Client + result["before"] = s.current.Modes[request.Client] + result["after"] = mode + result["reference"] = request.Reference + return result, nil + } + if request.Action == "apply" { + t, ok := s.pending[request.Confirmation] + if !ok || t.Actor != actor || t.Expires < s.now().Unix() || !request.Acknowledged { + return nil, errors.New("preview_expired_or_changed") + } + if (request.Client != "" && request.Client != t.Client) || (request.Mode != "" && request.Mode != t.Mode) || (request.Reference != "" && request.Reference != t.Reference) { + return nil, errors.New("preview_expired_or_changed") + } + for _, r := range s.current.History { + if r.Reference == t.Reference { + if r.Actor != actor || r.Client != t.Client || r.After != t.Mode { + return nil, errors.New("preview_expired_or_changed") + } + result := s.snapshot() + result["status"] = "recorded" + result["receipt"] = r + result["replayed"] = true + return result, nil + } + } + if s.current.Revision != t.Revision { + return nil, errors.New("policy_changed_review_again") + } + modes := map[string]string{} + for k, v := range s.current.Modes { + modes[k] = v + } + modes[t.Client] = t.Mode + receipt := Receipt{t.Reference, actor, t.Client, s.current.Modes[t.Client], t.Mode, s.current.Revision + 1, s.now().Unix()} + history := append(append([]Receipt{}, s.current.History...), receipt) + next := state{s.current.Revision + 1, modes, history} + if s.persist(next) != nil { + return nil, ErrUnavailable + } + s.current = next + result := s.snapshot() + result["status"] = "recorded" + result["receipt"] = receipt + return result, nil + } + return nil, errors.New("unsupported_policy") +} +func validReference(value string) bool { + if len(value) < 1 || len(value) > 150 { + return false + } + for _, r := range value { + if !(r >= 'a' && r <= 'z' || r >= 'A' && r <= 'Z' || r >= '0' && r <= '9' || strings.ContainsRune("_.:/-", r)) { + return false + } + } + return true +} +func Actor(claims map[string]interface{}, issuer string, now time.Time) (string, error) { + deny := errors.New("fresh_platform_mfa_required") + if claims["iss"] != issuer || claims["principal_type"] != "human" { + return "", deny + } + audience := false + switch value := claims["aud"].(type) { + case string: + audience = value == "user-engine-portal" + case []interface{}: + for _, v := range value { + if v == "user-engine-portal" { + audience = true + } + } + } + if !audience { + return "", deny + } + if azp, ok := claims["azp"]; ok && azp != "user-engine-portal" { + return "", deny + } + exp, ok := claims["exp"].(float64) + if !ok || exp <= float64(now.Unix()) { + return "", deny + } + if nbf, ok := claims["nbf"].(float64); ok && nbf > float64(now.Unix()) { + return "", deny + } + roles, ok := claims["roles"].([]interface{}) + if !ok { + return "", deny + } + operator := false + for _, v := range roles { + if v == "platform-operator" { + operator = true + } + } + if !operator { + return "", deny + } + a, ok := claims["assurance"].(map[string]interface{}) + if !ok || a["level"] != "aal2" || a["mfa"] != true { + return "", deny + } + at, ok := a["at"].(float64) + if !ok || float64(now.Unix())-at < 0 || float64(now.Unix())-at > 300 { + return "", deny + } + subject, ok := claims["sub"].(string) + if !ok || subject == "" || len(subject) > 150 { + return "", deny + } + return subject, nil +} +func Handler(store *Store, issuer string, key *rsa.PublicKey) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.Header().Set("Cache-Control", "no-store") + fail := func(code int, reason string) { + w.WriteHeader(code) + json.NewEncoder(w).Encode(map[string]interface{}{"success": false, "failure": reason}) + } + if r.Method != "POST" { + fail(405, "method_not_allowed") + return + } + token := strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer ") + if len(token) > 16384 { + fail(403, "fresh_platform_mfa_required") + return + } + claims, err := jose.Verify(token, jose.KeySet{"key-1": key}) + if err != nil { + fail(403, "fresh_platform_mfa_required") + return + } + actor, err := Actor(claims, issuer, time.Now()) + if err != nil { + fail(403, "fresh_platform_mfa_required") + return + } + decoder := json.NewDecoder(http.MaxBytesReader(w, r.Body, 8192)) + decoder.DisallowUnknownFields() + var request Request + if decoder.Decode(&request) != nil { + fail(400, "unsupported_policy") + return + } + var extra interface{} + if decoder.Decode(&extra) != io.EOF { + fail(400, "unsupported_policy") + return + } + result, err := store.Operation(actor, request) + if err != nil { + code := 409 + if errors.Is(err, ErrUnavailable) { + code = 503 + } + fail(code, err.Error()) + return + } + json.NewEncoder(w).Encode(result) + }) +} diff --git a/src/internal/server/policy/policy_test.go b/src/internal/server/policy/policy_test.go new file mode 100644 index 0000000..4becee7 --- /dev/null +++ b/src/internal/server/policy/policy_test.go @@ -0,0 +1,196 @@ +package policy + +import ( + "crypto" + "crypto/rand" + "crypto/rsa" + "crypto/sha256" + "encoding/base64" + "encoding/json" + "keycape/internal/domain" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + "time" +) + +func clients() map[string]*domain.Client { + result := map[string]*domain.Client{} + for _, id := range []string{"vergabe-demo-company", "user-engine-portal", "untouched"} { + result[id] = &domain.Client{ClientID: id, DisplayName: id, GrantTypes: []string{"authorization_code"}} + } + return result +} +func open(t *testing.T) *Store { + t.Helper() + s, e := Open(filepath.Join(t.TempDir(), "policy.json"), clients()) + if e != nil { + t.Fatal(e) + } + t.Cleanup(func() { s.Close() }) + return s +} +func preview(t *testing.T, s *Store, ref string) string { + t.Helper() + r, e := s.Operation("operator", Request{Action: "preview", Client: "vergabe-demo-company", Mode: Optional, Reference: ref}) + if e != nil { + t.Fatal(e) + } + return r["confirmation"].(string) +} +func apply(s *Store, ticket string) (map[string]interface{}, error) { + return s.Operation("operator", Request{Action: "apply", Confirmation: ticket, Acknowledged: true}) +} +func TestReviewedApplyReplayRestartRollback(t *testing.T) { + s := open(t) + key := preview(t, s, "case-1") + before, _ := s.Effective(clients()["vergabe-demo-company"]) + if before.MFAOptional { + t.Fatal("preview mutated policy") + } + if _, e := s.Operation("other", Request{Action: "apply", Confirmation: key, Acknowledged: true}); e == nil { + t.Fatal("wrong actor") + } + if _, e := s.Operation("operator", Request{Action: "apply", Confirmation: key}); e == nil { + t.Fatal("missing acknowledgement") + } + if _, e := apply(s, key); e != nil { + t.Fatal(e) + } + if r, e := apply(s, key); e != nil || r["replayed"] != true { + t.Fatal("lost response replay", e) + } + after, _ := s.Effective(clients()["vergabe-demo-company"]) + if !after.MFAOptional || after.MFARequired != nil { + t.Fatal("effective policy") + } + other := clients()["untouched"] + unchanged, _ := s.Effective(other) + if unchanged != other { + t.Fatal("unrelated registration changed") + } + s.Close() + restored, e := Open(s.path, clients()) + if e != nil { + t.Fatal(e) + } + defer restored.Close() + if len(restored.current.History) != 1 || restored.current.Modes["vergabe-demo-company"] != Optional { + t.Fatal("durability") + } + r, e := restored.Operation("operator", Request{Action: "rollback", Client: "vergabe-demo-company", Reference: "case-2"}) + if e != nil { + t.Fatal(e) + } + if _, e = apply(restored, r["confirmation"].(string)); e != nil { + t.Fatal(e) + } + if restored.current.Modes["vergabe-demo-company"] != Mandatory || len(restored.current.History) != 2 { + t.Fatal("rollback not audited") + } +} +func TestStaleExpiredUnsupportedAndAlteredRequests(t *testing.T) { + s := open(t) + first := preview(t, s, "first") + second := preview(t, s, "second") + if _, e := s.Operation("operator", Request{Action: "apply", Confirmation: first, Acknowledged: true, Mode: "disabled"}); e == nil { + t.Fatal("altered intent") + } + if _, e := apply(s, first); e != nil { + t.Fatal(e) + } + if _, e := apply(s, second); e == nil { + t.Fatal("stale review") + } + for _, r := range []Request{{Action: "preview", Client: "untouched", Mode: Optional, Reference: "x"}, {Action: "preview", Client: "user-engine-portal", Mode: "disabled", Reference: "x"}, {Action: "preview", Client: "user-engine-portal", Mode: Optional, Reference: "first"}} { + if _, e := s.Operation("operator", r); e == nil { + t.Fatal("unsupported request") + } + } + s2 := open(t) + ticket := preview(t, s2, "expire") + s2.now = func() time.Time { return time.Now().Add(time.Hour) } + if _, e := apply(s2, ticket); e == nil { + t.Fatal("expired review") + } +} +func TestWriteFailureDoesNotChangeEffectivePolicyAndSingleWriter(t *testing.T) { + s := open(t) + if second, e := Open(s.path, clients()); e == nil { + second.Close() + t.Fatal("second writer allowed") + } + ticket := preview(t, s, "fail") + s.path = filepath.Join(t.TempDir(), "missing", "policy.json") + if _, e := apply(s, ticket); e == nil { + t.Fatal("write failure accepted") + } + if s.current.Revision != 0 || s.current.Modes["vergabe-demo-company"] != Mandatory { + t.Fatal("failed write changed policy") + } +} +func TestCorruptPersistenceFailsClosed(t *testing.T) { + s := open(t) + s.Close() + os.WriteFile(s.path, []byte(`{"modes":{"other":"optional_after_enrollment"}}`), 0600) + if _, e := Open(s.path, clients()); e == nil { + t.Fatal("corrupt store accepted") + } +} +func signed(t *testing.T, key *rsa.PrivateKey, claims map[string]interface{}) string { + raw, _ := json.Marshal(claims) + header := base64.RawURLEncoding.EncodeToString([]byte(`{"alg":"RS256","kid":"key-1"}`)) + input := header + "." + base64.RawURLEncoding.EncodeToString(raw) + digest := sha256.Sum256([]byte(input)) + signature, e := rsa.SignPKCS1v15(rand.Reader, key, crypto.SHA256, digest[:]) + if e != nil { + t.Fatal(e) + } + return input + "." + base64.RawURLEncoding.EncodeToString(signature) +} +func TestHTTPRequiresSignedFreshPlatformMFA(t *testing.T) { + s := open(t) + key, e := rsa.GenerateKey(rand.Reader, 2048) + if e != nil { + t.Fatal(e) + } + handler := Handler(s, "https://issuer.test", &key.PublicKey) + base := func() map[string]interface{} { + return map[string]interface{}{"iss": "https://issuer.test", "aud": "user-engine-portal", "sub": "operator", "principal_type": "human", "roles": []string{"platform-operator"}, "exp": time.Now().Add(time.Minute).Unix(), "assurance": map[string]interface{}{"level": "aal2", "mfa": true, "at": time.Now().Unix()}} + } + for index, change := range []func(map[string]interface{}){func(c map[string]interface{}) {}, func(c map[string]interface{}) { c["aud"] = "other" }, func(c map[string]interface{}) { c["roles"] = []string{"tenant-admin"} }, func(c map[string]interface{}) { + c["assurance"] = map[string]interface{}{"level": "aal1", "mfa": false, "at": time.Now().Unix()} + }, func(c map[string]interface{}) { + c["assurance"] = map[string]interface{}{"level": "aal2", "mfa": true, "at": time.Now().Add(-time.Hour).Unix()} + }, func(c map[string]interface{}) { c["exp"] = 0 }} { + claims := base() + change(claims) + token := signed(t, key, claims) + r := httptest.NewRequest("POST", "/platform/authentication-policy", strings.NewReader(`{"action":"status"}`)) + r.Header.Set("Authorization", "Bearer "+token) + w := httptest.NewRecorder() + handler.ServeHTTP(w, r) + expected := 403 + if index == 0 { + expected = 200 + } + if w.Code != expected { + t.Fatalf("unexpected status %d", w.Code) + } + } + r := httptest.NewRequest("POST", "/platform/authentication-policy", strings.NewReader(`{"action":"status"}`)) + r.Header.Set("Authorization", "Bearer unsigned") + w := httptest.NewRecorder() + handler.ServeHTTP(w, r) + if w.Code != 403 { + t.Fatal("unsigned accepted") + } +} +func mustClaims(token string) map[string]interface{} { + raw, _ := base64.RawURLEncoding.DecodeString(strings.Split(token, ".")[1]) + var c map[string]interface{} + json.Unmarshal(raw, &c) + return c +}