Scoped Mandate — Implementierungsstand¶
Fachliches Brief: SCOPED_MANDATE_EUDIPal.md. Deployment der Mocks: ../tools/DEPLOY.md. Branch
feat/scoped-mandate, Stand 2026-10-10 (Build 747).
Ziel: Ein Shopping-Agent kauft im Namen des Nutzers ein, nach einer SCA. Die Bank haelt das Mandat und prueft den Payment-Scope, der Haendler prueft den Fach-Scope und signiert eine Attestation, der Agent hat eigene Identitaet und eigenen Key. Die Wallet entscheidet nie ueber Zahlungen.
Einordnung in die bestehende Architektur¶
| Rolle | Phase 1 (jetzt) | Phase 2 (Tausch hinter derselben Schnittstelle) |
|---|---|---|
| Bank | tools/mock-bank (Flask, Firestore) → https://bank.s-you.me |
PaSO-Referenzimplementierung |
| Haendler | tools/mock-merchant (Flask, Secret-Manager-Key) → https://shop.s-you.me, Haendler-Sicht /dashboard |
echtes UCP-Endpoint |
| Agent | ScopedAgentIdentity + Secure-Enclave-Key in der App |
EBW-Legal-Person-Credential |
| Wallet/SCA | PaSO-Pfad (OID4VPCoordinator, PaSOPaymentConsentView) mit transaction_data Typ generic_mandate |
PaSO-URN |
Die Swift-Schnittstellen liegen in Sources/ScopedMandate/Services/ScopedMandateProtocols.swift:
MandateServiceProtocol (Bank), MerchantServiceProtocol (Haendler),
AgentIdentityProtocol (Agent). Fehler sind immer ScopedMandateError mit
rejectedBy (bank | merchant) und Code — jede Ablehnung zeigt Grund und
Partei (Brief, Leitplanke).
Die aeltere Mandats-Schicht Sources/Mandate/ (M1/M2, PR #108: Wallet
signiert Mandate selbst) widerspricht dem Brief und bleibt vorerst daneben
stehen. Entscheidung dazu in AP6.
Dateien¶
BankingPal-Pocket/Sources/ScopedMandate/
├── Models/
│ ├── ScopedMandateTypes.swift Cents, ScopedMandate, PaymentScope, FunctionalScope,
│ │ MandateRequest (+ agent_public_key_jwk), GenericMandateTransactionData,
│ │ MerchantAttestation, AgentPaymentRequest, PaymentReceipt,
│ │ AgentIdentityCredential, ScopedMandateJSON (canonical)
│ └── ScopedMandateErrors.swift DecidingParty, BankRejectionReason (13), MerchantRejectionReason (7)
├── Services/
│ ├── ScopedMandateProtocols.swift Bank-/Haendler-/Agent-Protokolle, MandateActivityEntry (Outcome im Bank-Wire-Format), Hashing
│ ├── ScopedMandateHTTP.swift Endpunkte, JSON-Transport, Fehlermapping 422 → ScopedMandateError (Partei bleibt erhalten)
│ ├── HTTPMandateService.swift MandateServiceProtocol gegen bank.s-you.me; prueft transaction_data_hash lokal nach
│ └── HTTPMerchantService.swift MerchantServiceProtocol gegen shop.s-you.me; Discovery, prueft Attestation-Hash + Signatur
├── Agent/
│ ├── ECPublicKeyJWK.swift P-256 JWK + base64url
│ ├── AgentSigningKey.swift SecureEnclaveAgentKey, SoftwareAgentKey, Factory
│ ├── ScopedAgentIdentity.swift Credential, agentRef, JWK, MandateRequest-Bau; signedPaymentRequest als
│ │ Extension auf AgentIdentityProtocol
│ ├── ScopedMandateAgent.swift Einkaufs-Agent (AP7): myMandates, activeMandate, requestMandate (Consent),
│ │ purchase (Suche → Attestation → Zahlung), activity, revoke; ShoppingListParser
│ └── ScopedMandateTools.swift Tool-Definitionen get_scoped_mandates, request_scoped_mandate,
│ shop_with_mandate, get_scoped_mandate_activity, revoke_scoped_mandate
Sources/AI/ToolRegistry+ScopedMandate.swift Tool-Ausfuehrung: formatiert Ergebnisse und Ablehnungen (immer mit Partei)
Sources/AI/ContextBuilder.swift Prompt-Abschnitt "Einkaufs-Agent mit Bank-Mandat"
└── Consent/
├── ScopedMandateConsentCoordinator.swift Flow B: Anfrage → Consent → Geraete-SCA → lokaler Hash → authorize;
│ MandateAuthenticator (LAContext / Fake), ScopedMandateConsentRequest
└── MandateConsentView.swift MandateConsentSheet (Zustaende) + MandateConsentView (PaSO-Muster)
Aufhaengung: WalletHomeView praesentiert das Sheet auf `Notification.Name.scopedMandateConsentRequested`
(Object: ScopedMandateConsentRequest mit Rueckruf). Demo-Trigger: Einstellungen → Labor → "Agent-Mandat erteilen".
Tests/ScopedMandate/
├── ScopedMandateTypesTests.swift 12 Tests (JSON-Form, Cents, Hashes, Fehler)
├── ScopedAgentIdentityTests.swift 13 Tests (Credential, JWK, agent_proof wie die Bank, Key-Trennung)
├── ScopedMandateWireTests.swift 9 Tests ohne Netz (URLProtocol-Stub): Bank-Log-Format, Fehlermapping,
│ Hash-Gegenpruefung, Attestation-Signatur, Katalog
├── ScopedMandateConsentCoordinatorTests.swift 8 Tests (Fake-Bank, Fake-SCA): Hash = Anzeige, kein Bank-Call
│ ohne SCA, Abbruch, Ablehnung mit Partei, run() wartet auf Entscheidung
├── ScopedMandateAgentTests.swift 9 Tests (Fake-Bank, Fake-Haendler): Parser, Einkauf mit gueltigem agent_proof,
│ Haendler-/Bank-Ablehnung mit Partei + betroffenen Artikeln, Mandatswahl, Widerruf
└── ScopedMandateLiveTests.swift Flow A–D gegen die Live-Server; nur mit TEST_RUNNER_SCOPED_MANDATE_LIVE=1
tools/mock-bank/ app.py, dashboard.html, smoke_test.py (24), deploy.sh, allow-public.sh
tools/mock-merchant/ app.py, dashboard.html (Haendler-Sicht: Pruef-Log + Katalog, in-memory), smoke_test.py (23), deploy.sh
tools/setup-lb.sh HTTPS-Load-Balancer fuer bank./shop.s-you.me
Konventionen (gelten auf allen Seiten)¶
- Betraege als Cent-Integer (
Cents), JSON-Keys snake_case, Daten ISO-8601 sekundengenau. - Kanonische Form fuer Hashes/Signaturen: sortierte Keys, kein Whitespace, Slashes unescaped
(
ScopedMandateJSON.canonical, Pythoncanonical()). basket_hash,attestation_hash,ebw_credential_hash:sha256:<hex>.transaction_data_hash: base64url(SHA-256), bit-identisch zuPaSOTransactionDataService.transactionDataHash.- Signaturen: ES256 raw
r || s(64 Bytes), base64url. Gilt fueragent_proof(Agent → Bank) undsignatureder Attestation (Haendler). agent_proof= Signatur ueber die kanonische Form des Zahlungsauftrags ohneagent_proof.
Arbeitspakete¶
| AP | Inhalt | Stand |
|---|---|---|
| 1 | Typen, Fehler, Protokolle | ✓ Build 746 |
| 2 | Mandate Service (Bank-Mock) live, Dashboard | ✓ |
| 3 | Merchant Service (Katalog, Fach-Scope, Attestation) live | ✓ |
| 3b | Bank-Dashboard (Mandate, Limits, Aktivitaetslog, Widerruf) | ✓ |
| — | Domains bank.s-you.me / shop.s-you.me via Load Balancer |
✓ |
| 4 | Agent-Identitaet in Swift | ✓ Build 747 |
| 5 | Consent-Screen fuer generic_mandate (eigener Coordinator, PaSOPaymentConsentView-Muster, Geraete-SCA, lokaler Hash + MandateAuthorizationProof) |
✓ Build 750 |
| 6 | Mandats-Karte im Stapel (read-only, Spiegel der Bank) | offen — Entscheidung zu Sources/Mandate/ siehe unten (Build 751) |
| 7 | Chat-Flow: Tools get_scoped_mandates, request_scoped_mandate, shop_with_mandate, get_scoped_mandate_activity, revoke_scoped_mandate |
✓ Build 751 |
| 8 | Demo-Skript, Firestore-Testdaten leeren | offen |
| — | HTTPMandateService / HTTPMerchantService gegen die Live-Server, Live-Test Flow A–D |
✓ Build 749 |
| — | Interface-Dokument fuer den Google-PoC-Call: scoped-mandate-interfaces.md (EN) | ✓ |
Chat-Flow (AP7, Build 751)¶
Demo-Dialog: „Kauf mir 2 Milch, Brot und Spülmittel" →
get_scoped_mandates (keins) → request_scoped_mandate (Sheet, Face ID) → shop_with_mandate
(Katalog → Attestation → Zahlung) → Bericht mit Positionen, Summe, SCT-Inst-Referenz, Rest.
„Und ein Bier dazu" → Haendler lehnt ab (category_excluded), Antwort nennt Haendler, Grund und
Artikel. „Was hat der Agent gekauft?" → get_scoped_mandate_activity. „Widerrufen" →
revoke_scoped_mandate, danach lehnt die Bank ab.
- Keine Rueckfrage vor dem Kauf. Die eine SCA war das Mandat; der Agent kauft innerhalb der Grenzen autonom. Grenzen setzen Bank und Haendler durch, nicht der Prompt.
- Keine Vorab-Pruefung im Agenten. Jede Ablehnung soll von der zustaendigen Partei kommen und
so im Bank-Log stehen. Der Agent ermittelt nur zur Erklaerung, welche Positionen den Fach-Scope
verletzen (
offending). - Eine Identitaet pro App (
ScopedMandateAgent.sharedIdentity), sonst waere jeder Tool-Aufruf ein anderer Agent. ShoppingListParser: „2 Milch, Brot x3; Spülmittel (2) und Äpfel" → Mengen und Suchbegriffe; Katalogsuche bevorzugt exakte Namen, Fallback erstes Wort.
Entscheidung zu Sources/Mandate/ (M1/M2, PR #108): Die alten Tools list_mandates /
purchase_with_mandate (Wallet-signierte Mandate, PolicyEngine in der App) sind aus
AgentTools.all genommen — zwei Mandats-Modelle im selben Prompt widersprechen sich. Code,
Store, Views (Labor → Vollmachten) bleiben vorerst erhalten; endgueltiger Rueckbau nach dem
Google-Call, wenn klar ist, dass Phase 2 auf dem Bank-Mandat aufsetzt.
Consent / SCA (AP5, Build 750)¶
- Eigener Coordinator statt neuem
OID4VPCoordinator.State. Phase 1, Pfad (a), hat keinen Verifier-Roundtrip: Dietransaction_datakommen vomMandateRequestReceipt, nicht aus einem JAR.ScopedMandateConsentCoordinatorhaelt Flow B komplett; Phase 2 haengt denselben Consent hinter den OID4VP-Request der PaSO-Referenz (dann liefert der JAR dietransaction_data). - Die SCA ist echt. Im PaSO-Pfad passiert Face ID beim Signieren des KB-JWT mit dem
biometriegeschuetzten Credential-Key; ohne KB-JWT muss der Coordinator sie selbst machen:
LAContext.evaluatePolicy(.deviceOwnerAuthentication)mit dem Mandatstext als Begruendung („Mandat für ‚EUDIPal Einkäufer': bis 60,00 € je Einkauf, insgesamt 300,00 €, bei …, gültig bis …"). Das ist in Phase 1 das sichtbare What-you-see-is-what-you-sign.amrwie im PaSO-Pfad. - Hash = Anzeige. Der Nachweis traegt
base64url(SHA-256(canonical(transaction_data)))ueber genau die Daten, die die View gerendert hat — nicht den Hash, den die Bank mitschickt. - Kein Bank-Call ohne SCA. Abbruch oder Fehlversuch im Face-ID-Dialog fuehrt zurueck zum
Consent;
authorizewird erst nach erfolgreicher Authentifizierung gerufen (Test). - Jeder Fehler zeigt die Partei: „Die Sparkasse hat abgelehnt" / „Der Händler hat abgelehnt" / technisch — plus Code und Klartext.
run(request)liefert einOutcome(authorized / declined / failed), sobald der Nutzer entschieden hat — das ist die Schnittstelle fuer das Chat-Tool in AP7.
HTTP-Schicht (Build 749)¶
ScopedMandateEndpoints.bank/.shopsind die festen PoC-URLs; Phase 2 tauscht nur die URL (oder stellt eine zweite Protokoll-Implementierung daneben).- Kein Retry, kein Cache: Mandatsanfrage und Zahlung sind fachlich einmalig.
- Fehlermapping: HTTP 422 mit
{"error":{"rejected_by","code","message"}}wird zurejectedByBank/rejectedByMerchant; kennt die App den Code nicht, zu.rejected(by:code:message:)— die Partei geht nie verloren. Alles andere ist.technical. - Gegenpruefungen auf Client-Seite:
requestMandatebildet dentransaction_data_hashselbst und vergleicht mit der Bank (Wallet hasht, was sie anzeigt — Abweichung = Bug in der kanonischen Form, laut scheitern).attestprueftattestation_hashund die ES256-Signatur gegen den Discovery-Key, bevor eine Attestation in einen Zahlungsauftrag geht. MandateActivityEntry.Outcomehat eigenes Codable im Bank-Format ({"executed": …},{"rejected": {…}},{"revoked": {}},{"authorized": {}}).- Live-Test:
TEST_RUNNER_SCOPED_MANDATE_LIVE=1 xcodebuild test … -only-testing:EudiPalTests/ScopedMandateLiveTests(Variable als Umgebung des xcodebuild-Prozesses, nicht als Argument — sonst wird uebersprungen). Hinterlaesst ein widerrufenes Test-Mandat im Dashboard. Stand 10.10.2026: gruen in 1,7 s.
Vorgaben Sparkasse (10.10.2026)¶
Vier Punkte aus der Abstimmung mit der Sparkasse und wie sie umgesetzt sind (Build 748):
| # | Vorgabe | Umsetzung |
|---|---|---|
| 1 | Agent und Plattformanbieter weisen sich gegenueber der Sparkasse aus | MandateRequest.agent_credential traegt das vollstaendige Credential (Agent-ID, Anzeigename, Betreiber, Registernummer, Key). Die Bank prueft Kurzform ↔ Credential, ebw_credential_hash und Key ↔ JWK; sonst agent_identity_invalid (rejected_by bank). Credential bleibt im Mandats-Spiegel, Dashboard zeigt Agent + Anbieter. Phase 2: zusaetzlich EBW-Signatur. |
| 2 | Mandat laeuft unter SPAA, Dynamic Recurring Payment | PaymentScope.scheme = "spaa_drp" (Default in Swift und Bank), Discovery nennt Scheme, Dashboard zeigt es. |
| 3 | Kunde kann das Mandat jederzeit in der S-App widerrufen | Bankseitig POST /mandates/{id}/revoke (idempotent) + Dashboard-Button — vorhanden. Walletseitig kommt der Widerruf mit der Mandatskarte in AP6. Die S-App ist im PoC durch das Bank-Dashboard vertreten. |
| 4 | Settlement ist eine SCT-Inst-Transaktion | PaymentRail.sctInst ist Default (statt Wero aus dem Brief). Jeder PaymentReceipt traegt settlement { instrument: sct_inst, scheme, end_to_end_id, status: simulated }. |
Abweichung vom Brief: Dort stand Wero als Rail. Wero bleibt als Wert erhalten, ist aber nicht mehr Default.
Entscheidungen in AP4¶
- Eigener Secure-Enclave-Alias
eudipal.scoped-agent.signer.v1, getrennt von Wallet-Keys, Nutzer-Mandats-Key (eudipal.mandate.signer.v1) und dem M1/M2-Agent-Key (eudipal.agent.signer.v1). - Keine Biometrie am Agenten-Key. Die eine SCA ist das Mandat; der Agent signiert Zahlungsauftraege autonom. Was er darf, begrenzt die Bank ueber den Payment-Scope. Face ID pro Zahlung wuerde das Konzept aushebeln.
agent_iddeterministisch aus dem Public Key (agt_+ 16 Hex SHA-256 ueber SEC1). Credential und Key koennen nicht auseinanderlaufen; anderer Key ⇒ anderer Agent. Reinstall ⇒ neue Identitaet ⇒ Mandate muessen neu erteilt werden (gewollt).- Credential unsigniert. Eine Selbstsignatur mit Platzhalter-Issuer wuerde nichts
beweisen. Phase 2 bringt das EBW-signierte Credential;
AgentIdentityProtocolbleibt gleich. - Simulator:
SoftwareAgentKeynur fuer die Laufzeit (Log-Hinweis). Demo laeuft auf dem Geraet. MandateRequest.agentPublicKeyJwkergaenzt — die Bank verlangt den JWK bei/mandate-requests, er ist aber bewusst nicht Teil dertransaction_data.
Sicherheit¶
- Signing-Keys nie im Repo (
*.pem,/keys/in.gitignore); Haendler-Key nur im Secret Manager. - Agenten-Key nur im Secure Enclave; das Credential enthaelt ausschliesslich oeffentliche Daten.
- Die Bank sieht nie Warenkorb-Positionen, nur Hashes (Brief, Grundsatz 2).