Skip to main content
Design only. Nothing on this page is implemented, and none of it starts without explicit approval - PR by PR, in the order at the end. Today the app reads /v1/mobile and sends nothing (Operator App).
Decided so far:
  • Phase 2 uses hardware-backed device keys, not OIDC.
  • It has four-eyes approval. Which actions need it is proposed in the table below and not yet settled; the APERAK acknowledgement is the open case.
  • Native code talks to Flutter through the app’s own channels, with no plugins.
  • The watch stays read-only. So do the widgets and the Live Activity.
Still open is the list at the end, among them Android push (FCM or Pushover) and whether Wiedervorlagen may be closed on the phone.

What stays as it is

  • The read router stays GET-only. Today’s test asserts that for every path under /v1/mobile. It will exclude exactly two things:
    • /v1/mobile/actions/*, which gets its own allowlist test;
    • the push-token route of phase 3.
  • On its own, the read key sends nothing and changes nothing in the pipeline. It can read, ask for previews, enrol a signing key that then waits for approval in the console, and, in phase 3, register a push token.
  • No bearer secret alone can do anything. Every action needs a fresh signature from a key that never leaves the phone’s secure hardware, and each signature needs the operator’s biometrics.
  • The server makes every plan; the phone shows it and signs it. The phone never builds a target list or a message that goes out.
  • The one exception is the Notiz, whose text the operator types. The preview shows that text exactly as Cloover will read it. The person is set by the server from the handle, never by the phone.
  • Nothing is sent automatically and nothing without a preview. There is no “execute without preview”, neither in the app nor on the server: execute accepts nothing but a preview id.
  • Mobile payloads carry no MaLo or MeLo, no partner contact data and no partner free text, also in previews.

Phase 2: actions

Which actions

The candidates come from the brief. Each wraps an existing service function; four of them already have a dry_run the preview can use. Never on the phone:
  • orchestrate and the manual pipeline steps;
  • PUT /v1/console/config, mailbox processing, all mail/*;
  • single or custom Zuordnungen, the PARTIN fan-out;
  • Korrektur, Nachfass and Nachversand sends, override flags;
  • imports, partner-book edits, and the external and quellstand writes.
Stopping sends nothing, but it stops the cycle that submits T+1 before the gate closes. So the server refuses it from warn_from (closure minus gate_closure_warn_minutes) to the closure while T+1 is not final.The backend has no “final” yet. The proposal: T+1 is final when its badge code is accepted or imbalance, meaning the current version was submitted and accepted, balanced or not. (old_version_accepted occurs only after closure, so it does not matter here.)The console’s own stop stays as it is.
A Wiedervorlage is keyed by the sender’s e-mail address, by an MP-ID, or by a Message-ID. None of these may reach the phone, so closing one there needs opaque, server-side ids that map back to the key.Without the mail’s address and text, the phone also has too little to decide on. The proposal: not in phase 2. If you want it later, it closes without moving the mail, because IMAP is mailbox processing.

Identity: a signing key per phone

Each phone gets a second key, next to its read key: a P-256 signing key that cannot leave the secure hardware and needs biometrics for every signature. Native code reaches it through a new bridge/signing channel (enrol, sign, delete), like the existing bridge/key.
  • The key lives in the Secure Enclave, created with .privateKeyUsage and .biometryCurrentSet. A new fingerprint or face enrolled on the phone invalidates it.
  • iOS cannot attest such a key. The enrolment check is therefore human: the console and the app show the key’s fingerprint, and the operator compares them. A re-signed copy of the app would show its own key’s fingerprint and pass that check. This is an accepted residual risk: the app comes only through the operator’s own distribution, and each phone is approved by someone who holds it.
  • App Attest is not proposed. It proves a genuine app build, not the biometric key, and adds Apple’s attestation service.
The watch never gets a signing key. The device registry records the kind of each device (phone or watch), set by the operator in the console, and enrolment is refused for anything but a phone. Wiping the app or deleting the key on the phone deletes the signing key too. A new fingerprint or face enrolled on the phone invalidates the key on both platforms. The app then shows “Schlüssel für Aktionen neu einrichten”. Re-enrolment revokes the old public key in the registry, and the new one waits for approval in the console again.

Enrolment

  • Proof of possession: on both platforms the enrolment carries a signature over the nonce, made with the new key.
  • Only one pending enrolment per device. A new challenge voids the previous one.
  • The fingerprint is the SHA-256 of the public key’s SPKI. Both sides show at least its first 128 bits, as eight groups of four hex digits.
The server keeps a device registry, a new ledger mobile_devices.json on the audit share. For each device it holds:
  • the name and the person’s handle;
  • the kind (phone or watch);
  • the public key and its fingerprint;
  • the attested security level;
  • when it was enrolled and approved;
  • its state: pending, active or revoked.
Four-eyes counts people, not devices. The handle in the registry is set by the operator in the console, not parsed from the name. Each approval in the console is audited (mobile_device_approved), and the console lists every active device with its handle. Whoever holds the process key could still register a second handle for themselves; see the threats below and decision 4.

Revocation without a restart

Today, revoking a device means removing its hash from MOBILE_API_KEY_HASHES and restarting the revision (runbook). Phase 2 adds POST /v1/console/mobile-devices/{name}/revoke, in the process scope, which writes a denylist to the audit share. The denylist holds the read key’s hash and the signing key’s fingerprint, not the device name, so a new key issued later under the same name works. From the next request on, it:
  • blocks the device’s read key;
  • blocks its signing key;
  • ends the four-eyes requests it opened;
  • deletes its push tokens (phase 3).
The hash can leave Key Vault with the next routine change. This is useful even before any action exists, for a lost phone, so it is the first PR.

Preview, confirm, execute

  • Router. The actions sit on a separate /v1/mobile/actions/* router holding an explicit allowlist. Each action has its parameter schema, its preview, its execute and its policy (one person or four eyes). A test fails on any route the allowlist does not name. GET /v1/mobile/actions lists what this device may do now, and why not where it may not (for example a stop in the gate window).
  • Preview. Where the service function has a dry run, the preview runs it with an explicit dry_run=True. For start, stop and the Notiz it states the effect. Only an active, enrolled phone may ask, with at most five open previews per device. A preview returns:
    • the plan in German and English;
    • for the actions that send, the targets as partner names and MP-IDs;
    • a plan_hash over the plan’s content (targets, message types, versions), leaving out time stamps.
    The preview is bound to the device and single-use, expires after 5 minutes, and is kept in memory, because there is one replica. A restart loses it, and the operator previews again. For each action, a test proves that its preview sends nothing: sockets blocked, AS4, SMTP, sFTP and Slack mocked, and the audit share diffed, leaving out the MakoFlow cache. Today one dry run fails that test: acknowledge_received(dry_run=True) still emits aperak_needs_review, which reaches the Cloover feed. It gets a dry_run guard before the action exists.
  • Signature. The phone signs a domain-separated text, not JSON, so nothing needs canonicalising:
    It is ECDSA P-256 with SHA-256. The server accepts issued_at within ±60 s of its clock.
  • Execute. Execute takes a preview id, an idempotency key, issued_at and the signature, and nothing else. It checks, in this order:
    1. the device is active and the signature is valid within ±60 s;
    2. the idempotency key: a known one, bound to this device and preview, returns its stored result and sends nothing;
    3. the preview is unused, unexpired and bound to this device.
    It then stores the key as pending, takes the send lock (see below), runs the dry run again (409 plan_changed if the hash differs), checks the target cap, and only then sends.
  • Only to the plan’s targets. The sending functions work out their targets afresh from MakoFlow on every call. A partner that turned up after the preview would otherwise get a message nobody saw or signed. So execute hands the plan’s targets to the service function through a new nur= parameter, and it sends to those and no others. A new target waits for the next preview. This parameter is part of step 2c.
  • Idempotency. Keys are kept for 24 hours in a ledger, so they survive a restart. A repeated key returns the stored result and sends nothing. A key still pending after a crash answers “unknown - check in the console” and never sends a second time. The app retries a failed request only when the operator taps it, and with the same key.
  • Target cap. Each execution has a maximum number of targets (proposal: 20, MOBILE_ACTION_MAX_TARGETS). A larger plan is refused with “bitte in der Konsole”.
  • The send lock. Today nothing serialises the sends: orchestrate takes no lock, and neither do the /v1/process routes that send. The lock covers only the three sending loops inside orchestrate (answer_partins, acknowledge_received, auto_zuordnung), the same /v1/process routes and every execute. The day steps and the mailbox never wait for it, so a slow execute cannot hold up a submission before the gate. The service runs as one replica with one process, so an in-process lock is enough. Execute waits at most 30 seconds, then answers 409 busy. A second replica would need a lease on the share instead.
  • Rate limits. Actions get their own small budget, separate from reads.

Four-eyes

  • An action that needs four eyes is executed by the second signature, from a different handle. A second phone of the same person does not count.
  • The approver gets a dry run of their own. If its plan hash differs from the requester’s, the request is void and has to be started again. Execute sends only to the requester’s targets, as above.
  • A request lives for 30 minutes (proposal) and is kept in a ledger, so it survives a restart. Either person may withdraw or reject it, with a signature as well.
  • A request executes at most once. Approve, reject and withdraw are serialised per request, and the approval carries an idempotency key of its own.
  • The approver’s signature covers the request id and the decision as well as the fields above (bridge-approval-v1).
  • Which actions need four eyes is server-side policy, set by environment (MOBILE_FOUR_EYES_ACTIONS) and not changeable through the console’s configuration. The app only shows it.
  • It needs at least two enrolled people. With one, a four-eyes action is not offered on the phone. The console, with the process key, remains the fallback, as it is for everything today.
  • Without push, the second person learns of a request in the app (“1 Freigabe offen” on Heute) or by a call. With phase 3, a push without content (“Bridge: Freigabe”) tells them and opens “Freigaben”.

Audit

New events in the catalog:
  • mobile_device_enrolled, mobile_device_approved, mobile_device_revoked;
  • mobile_action_requested, mobile_action_approved;
  • mobile_action_closed, with the reason: rejected, withdrawn, expired or voided by a changed plan;
  • mobile_action_executed.
mobile_action_executed records:
  • the caller and the device;
  • the approver, where there was one;
  • the action, the preview and the plan hash;
  • the target MP-IDs;
  • the result.
The mobile_* events are already kept out of Slack: the Cloover feed drops the prefix, and alerting fires only for the types it names. They stay out of the app’s own event feed as well. The middleware’s api_call event records every state-changing call with its caller, as it does today. The service functions themselves stay unaware of who called them.

The app

  • Heute gains “Aktionen” and, where requests are open, “Freigaben”. The preview shows the server’s plan word for word, with “Senden” or “Zur Freigabe senden”; each one asks for the fingerprint or face.
  • It keeps no queue and makes no automatic retry. A tap on “erneut versuchen” repeats the request with the same idempotency key.
  • The watch, the widgets and the Live Activity stay read-only.

Threats

Phase 3: native push

Push tells the operator that something happened; the text is only read in the app.
  • Payloads carry no market data. They hold a fixed German title per level - “Bridge: Fehler”, “Bridge: Warnung” and, with phase 2, “Bridge: Freigabe” - plus the event id. The app shows the text after it is opened, from /v1/mobile/events.
  • A Notification Service Extension that fetches the text is not proposed, because it would be one more target holding the key.
  • What triggers a push follows the existing rules in alert_for: EMERGENCY gives “Bridge: Fehler”, HIGH gives “Bridge: Warnung”, and NORMAL never pushes. There is one push per event id.
    • Alert carries no event id, so the push notifier works from the event itself, through the event log’s subscriber hook.
    • “Bridge: Freigabe” is not an alert: it carries the request id and opens “Freigaben”, because mobile_* events never reach the app’s event feed.
  • Pushover stays as the EMERGENCY backstop. APNs and FCM cannot repeat until acknowledged.
  • Tokens. POST and DELETE /v1/mobile/push/token write only the token store. That is the one named exception to the read-only test of /v1/mobile.
    • A watch key or a revoked device gets no token.
    • Each caller has at most three tokens, and a fourth is refused rather than replacing one.
    • Tokens are deleted on revocation and on a wipe; a token APNs reports as unregistered is dropped, and the console can clear a device’s tokens.
  • An ApnsNotifier next to the existing notifiers. Its settings:
    • a .p8 token key from the environment, in SECRET_FIELDS;
    • an ES256 JWT, renewed before its hour runs out;
    • HTTP/2 with a 10 s timeout. This needs h2 as a new dependency.
  • A notification category with hiddenPreviewsBodyPlaceholder.
  • The time-sensitive interruption level for EMERGENCY, which needs its entitlement.
  • The watch mirrors the iPhone’s notifications; it gets no push of its own.
  • The Live Activity could then change colour while the app is closed, through its own push token. Its content state holds the badge, the closure, the data’s time and LIVE/TEST - no energy, no partners - but Apple would see it. That needs a decision of its own.

Order, once approved

Each step is its own PR, with green CI and an independent review.
  1. 2a - Device registry and revoke without restart. Adds the denylist for read keys and the console route. It is useful at once for a lost phone.
  2. 2b - Enrolment. Adds the challenge, Android attestation, approval in the console with the fingerprint, and the app’s bridge/signing channel. No action yet.
  3. 2c - The actions router. Brings the allowlist test, preview and execute, the send lock, idempotency, the nur= parameter on the sending functions, the dry_run guard for aperak_needs_review and the audit events. Its first action is start the service, the one with the least risk.
  4. 2d - Stop the service with the gate rule, and the Protokoll-Notiz.
  5. 2e - Four-eyes, with answer incoming PARTINs as its first action.
  6. 2f - The remaining actions, one per PR.
  7. 3a - The token store and APNs, then the Live Activity push if approved. Android follows the FCM decision.

Open decisions

1

Four eyes per action

The table above is a proposal. In particular: should the APERAK acknowledgement, which goes to market partners, and the Notiz need four eyes as well?
2

Four-eyes requests

30 minutes of life, and voided when the plan changes. Should a request also lapse at the gate closure?
3

T+1 final

For the stop rule: is T+1 final when its badge code is accepted or imbalance?
4

Enrolment approval

Is the console approval by whoever holds the process key enough, or should it be a second person?
5

Target cap

20 targets per execution, or another number?
6

Wiedervorlagen

Not in phase 2 (proposal), or closing without moving the mail?
7

Android push

FCM with Google as a processor, or Pushover as today (proposal)?
8

Live Activity push

May the badge state go through APNs?
9

Identifiers

The attestation check needs the final Android application ID (Q11: ch.nlead.bridge_app is a placeholder) and its signing-certificate digest. APNs needs the iOS bundle ID (Q10).