Antworten senden und lesen (BiDiKo)
TL;DR – Onlinedienst erzeugt einmal pro Case ein Schlüsselpaar und legt den öffentlichen Schlüssel als ReplyChannel an die Submission. Verwaltungssystem antwortet verschlüsselt darauf; Onlinedienst holt die Antwort ab und entschlüsselt sie mit dem privaten Schlüssel.
Wer macht was?
Onlinedienst
- Schlüsselpaar erzeugen
- Submission mit ReplyChannel senden
- Weitere Submissions dem Case zuordnen
- Replies abholen & entschlüsseln
- Reply akzeptieren oder zurückweisen
Verwaltungssystem
- Submission empfangen
caseIdund Verschlüsselungsschlüssel persistieren- Reply verschlüsselt senden
Ablauf im Überblick
Alle Submissions und Replies eines Dialogs werden durch dieselbe caseId verklammert.
Eine Antwort adressiert keinen Zustellpunkt, sondern den Vorgang (Regel 3 des Rollenmodells). Und der Rückkanalschlüssel gehört zu einem Vorgang, nicht zum Onlinedienst — deshalb muss er pro Vorgang erzeugt und aufbewahrt werden, und deshalb nimmt das SDK beim Lesen keinen Schlüssel, sondern eine Nachschlagefunktion.
Onlinedienst — was Sie implementieren
Schlüsselpaar für den Rückkanal erzeugen
Pro Vorgang (Case) erzeugen Sie genau ein Schlüsselpaar. Der private Schlüssel verbleibt sicher beim Onlinedienst und entschlüsselt später die Replies; der öffentliche Schlüssel wird der Initial-Submission als Teil des ReplyChannels mitgegeben.
- Java
- .NET (C#)
JWKPair pair = EncryptionKeyPairGenerator.generate();
JWK privateReplyDecryptionKey = pair.privateKey(); // sicher verwahren!
JWK publicReplyEncryptionKey = pair.publicKey(); // im ReplyChannel mitsenden
Die generierten Schlüssel basieren nicht auf einem V-PKI-Zertifikat. Sie sind sogenannte Ephemeral Keys und dürfen nur für das Ver-/Entschlüsseln von Replies innerhalb eines Vorgangs eingesetzt werden – nicht beim Einrichten eines Zustellpunkts.
// RSA-4096-Paar erzeugen und als JWK serialisieren (Microsoft.IdentityModel.Tokens)
using var rsa = RSA.Create(4096);
var securityKey = new RsaSecurityKey(rsa) { KeyId = Guid.NewGuid().ToString() };
JsonWebKey privateJwk = JsonWebKeyConverter.ConvertFromRSASecurityKey(securityKey);
string replyPrivateKeyJson = JsonSerializer.Serialize(privateJwk); // sicher verwahren!
ApiJwk replyPublicKey = JsonSerializer.Deserialize<ApiJwk>(
JsonSerializer.Serialize(JsonWebKeyConverter.ConvertFromRSASecurityKey(
new RsaSecurityKey(rsa.ExportParameters(false)) { KeyId = securityKey.KeyId })))!;
Das .NET-SDK bringt keine eigene Utility zum Erzeugen von Ephemeral-JWK-Paaren mit —
Fitko.FitConnect.Core.Crypto deckt Ver- und Entschlüsselung ab. Nehmen Sie
System.Security.Cryptography.RSA plus Microsoft.IdentityModel.Tokens (wie oben), das
JWK-Tool oder die Java-Kommandozeile
(fit-connect keygen). Das Beispielprojekt Fitko.FitConnect.Examples (ReplyExample.cs) zeigt
den vollständigen Ablauf.
ReplyChannel an die Submission anhängen
- Java
- .NET (C#)
Der öffentliche Schlüssel wandert als ReplyChannel in die Submission — er sagt dem
Verwaltungssystem, wie und womit es antworten soll:
var replyChannel = ReplyChannels.fitConnect(
publicReplyEncryptionKey,
List.of("urn:xoev-de:bmk:standard:xbau_2.3")
);
Empfänger adressieren, wie beim normalen Senden:
OnlineService onlineService = sdk.onlineService(myDestinationId);
Participant recipient = Participant.of(
destinationId,
Addressing.toService("urn:de:fim:leika:leistung:99400048079000", "FIT-Connect Demo"));
Und die Submission mit dem ReplyChannel senden:
OutgoingSubmission submission = OutgoingSubmission.to(recipient)
.setData(SubmissionData.json("{\"message\":\"Hello World\"}", schemaUri))
.setReplyChannel(replyChannel)
.build();
SentSubmission sent = onlineService.send(submission);
// Persistieren: sent.caseId() und privateReplyDecryptionKey
IOnlineServiceClient onlineService = client.AsOnlineService();
// Öffentlicher Rückkanalschlüssel + Ihre eigene Destination, an die geantwortet werden soll
ReplyChannel replyChannel = ReplyChannel.OfFitConnect(replyPublicKey, myDestinationId);
OutgoingSubmission submission = OutgoingSubmissionBuilder.Builder()
.WithDestinationId(destinationId)
.WithServiceType("urn:de:fim:leika:leistung:99400048079000", "FIT-Connect Demo")
.WithMetadataVersion(new Version(1, 2, 0))
.WithJsonData(jsonData, schemaUri)
.WithReplyChannel(replyChannel)
.Build();
SentSubmission sent = await onlineService.SendSubmission(submission);
await vault.Store(sent.CaseId, replyPrivateKeyJson); // pro Vorgang merken
OfFitConnect(encryptionKey, destinationId, supportedSchemas?) nimmt optional die Schemata, die Sie
in Antworten akzeptieren.
Weitere Submissions dem Case zuordnen
Für alle folgenden Submissions desselben Vorgangs setzen Sie dieselbe caseId:
- Java
- .NET (C#)
UUID existingCaseId = sent.caseId(); // aus der Initial-Submission
OutgoingSubmission followUp = OutgoingSubmission.to(recipient)
.inCase(existingCaseId) // <— gleiche caseId wie Initial-Submission
.setData(SubmissionData.json("{\"message\":\"Nachreichung\"}", schemaUri))
.setReplyChannel(replyChannel)
.build();
onlineService.send(followUp);
OutgoingSubmission followUp = OutgoingSubmissionBuilder.Builder()
.WithDestinationId(destinationId)
.WithServiceType("urn:de:fim:leika:leistung:99400048079000", "FIT-Connect Demo")
.WithMetadataVersion(new Version(1, 2, 0))
.WithJsonData(jsonData, schemaUri)
.WithCaseId(existingCaseId) // <— gleiche caseId wie Initial-Submission
.WithReplyChannel(replyChannel)
.Build();
await onlineService.SendSubmission(followUp);
Replies abholen
- Java
- .NET (C#)
Dieser Abruf läuft typischerweise als eigener Poll-Lauf, ggf. in einem separaten Prozess –
deshalb wird der OnlineService hier erneut erzeugt, diesmal mit ReplyKeys zum Entschlüsseln.
ReplyKeys ist ein funktionales Interface (Case-ID → Schlüssel) und wird einmal beim Erzeugen
übergeben, nicht mehr pro Abruf. Bei mehreren offenen Cases reicht eine Map, ein Vault-Lookup
per Methodenreferenz (vault::lookupReplyKey) funktioniert ebenso:
ReplyKeys replyKeys = ReplyKeys.of(Map.of(sent.caseId(), privateReplyDecryptionKey));
OnlineService onlineService = sdk.onlineService(myDestinationId, replyKeys);
Dann abholen und verarbeiten:
RepliesForPickup replies = onlineService.awaitingReplies(/* offset */ 0, /* limit */ 25);
for (ReplyForPickup r : replies.replies()) {
ReceivedReply received = onlineService.receive(r);
String data = received.getDataAsString();
URI schema = received.getDataSchemaUri();
for (Attachment a : received.getAttachments()) {
try (InputStream in = a.openStream()) {
// verarbeiten
}
}
if (received.report().acceptable()) {
onlineService.accept(received);
} else {
onlineService.reject(received, received.report().asProblems());
}
}
Der Rückkanalschlüssel gehört zu einem Vorgang, also übergeben Sie dem SDK eine
Nachschlagefunktion (IReplyKeys): ReplyKeys.FromLookup(vault.Find) adaptiert Ihren
Vault (Func<Guid caseId, string? jwkJson>), ReplyKeys.Of(dictionary) reicht für eine
In-Memory-Map, ReplyKeys.None() erklärt, dass dieser Onlinedienst nie Antworten liest.
IOnlineServiceClient onlineService = client.AsOnlineService();
IReplyKeys replyKeys = ReplyKeys.FromLookup(vault.Find);
List<ListedReply> availableReplies = await onlineService.FetchAvailableReplies() ?? [];
foreach (ListedReply r in availableReplies)
{
IncomingSubmission received = await onlineService.FetchSpecificReply(r.ReplyId, replyKeys);
string data = received.GetDataAsString();
foreach (Attachment a in received.Attachments)
{
await using Stream stream = a.OpenStream();
// verarbeiten
}
if (received.Acceptable())
await onlineService.AcceptReply(received);
else
await onlineService.RejectReply(received, received.Report.AsProblems().ToList());
}
Fehlt der Schlüssel für einen Vorgang, benennt FitConnectReplyException Vorgang und Antwort —
statt eines kryptischen Entschlüsselungsfehlers.
Reply akzeptieren oder zurückweisen
- Java
- .NET (C#)
Auch hier gilt das Report-getriebene Muster: received.report().acceptable() (bzw.
kurz die Fehlerliste in report().errors()) entscheidet, kein Auto-Reject durch das SDK:
if (received.report().acceptable()) {
// Akzeptieren
onlineService.accept(received);
} else {
// Zurückweisen — Report-Fehler direkt weiterreichen …
onlineService.reject(received, received.report().asProblems());
}
// … oder mit eigenen, zusätzlichen Problem-Typen zurückweisen:
onlineService.reject(received, List.of(
new DataSchemaViolation("Feld X fehlt"),
new MyCustomProblem("Stadtname existiert nicht")));
// Akzeptieren
await onlineService.AcceptReply(received);
// Zurückweisen — Report-Fehler direkt weiterreichen …
await onlineService.RejectReply(received, received.Report.AsProblems().ToList());
// … oder mit eigenen Problem-Typen
await onlineService.RejectReply(received,
[
new DataJsonSyntaxViolation(),
new MyCustomProblem("Stadtname existiert nicht"),
]);
Der Reply wird nach accept-reply oder reject-reply im Zustelldienst dauerhaft gelöscht.
Alle benötigten Daten vorher sichern.
Verwaltungssystem — was Sie implementieren
Das Verwaltungssystem empfängt Submissions über den normalen Organisation-Weg. Der einzige BiDiKo-spezifische Schritt ist das Persistieren des ReplyChannels und das Senden des Replies.
Reply senden
Die Empfängerseite baut den Reply auf dem Case der ursprünglichen Submission auf und
verschlüsselt ihn mit dem replyEncryptionKey, den der Onlinedienst im ReplyChannel
mitgegeben hat.
- Java
- .NET (C#)
OutgoingReply.answering(caseId, replyEncryptionKey) baut die Antwort, ohne den ursprünglichen
Antrag im Speicher zu halten — ideal, wenn caseId und Schlüssel wie hier aus dem Fachverfahren
kommen:
UUID caseId = getCaseIdFromBusinessSystem();
JWK replyEncryptionKey = getReplyEncryptionKeyFromBusinessSystem();
OutgoingReply reply = OutgoingReply.answering(caseId, replyEncryptionKey)
.setData(SubmissionData.json(
replyJson, URI.create("https://schema.example.com/bescheid.json")))
.addAttachment(Attachment.fromFile(
Paths.get("bescheid.pdf"), "application/pdf"))
.build();
Gesendet wird über die eigene Organisation:
Organisation organisation = sdk.organisation(myDestinationId, keys);
SentReply sent = organisation.send(reply);
Haben Sie den Antrag noch als ReceivedSubmission vorliegen, geht es kürzer mit
OutgoingReply.answering(received) (Case und Schlüssel werden dann daraus übernommen).
Wenn ein Case mehrere Submissions enthält, kann sich der replyEncryptionKey ändern.
Verwenden Sie immer den Schlüssel der neuesten ReceivedSubmission im Case.
ReplyRequestBuilder.ForCase(caseId).EncryptWith(key) baut die Antwort aus den persistierten
Werten, ohne den ursprünglichen Antrag im Speicher zu halten:
IOrganisationClient organisation = client.AsOrganisation();
ReplyRequest replyRequest = ReplyRequestBuilder
.ForCase(caseId)
.EncryptWith(replyEncryptionKey) // ApiJwk aus dem ReplyChannel des Antrags
.WithMetadataVersion(new Version(1, 2, 0))
.WithJsonData(replyJson, new Uri("https://schema.example.com/bescheid.json"))
.WithAttachment(Attachment.FromFile("./bescheid.pdf", "application/pdf"))
.Build();
SendReplyResponse sent = await organisation.SendReply(replyRequest);
Haben Sie den Antrag noch als IncomingSubmission vorliegen, geht es kürzer mit
ReplyRequestBuilder.ForSubmission(received) — Vorgang und Schlüssel werden daraus übernommen.
Den Zustand der gesendeten Antwort liefert organisation.GetReplyState(sent).
Häufige Stolperfallen
Vollständige Liste
caseIdoder Schlüssel nicht persistiert: Der Onlinedienst musscaseIdund den privaten Schlüssel persistieren. Das Verwaltungssystem musscaseIdund den öffentlichen Schlüssel persistieren. Ohne diese Daten können Replies weder entschlüsselt noch gesendet werden.- Falscher Prozessstandard: Im
ReplyChannelmüssen die im Vorgang zulässigen Prozessstandards (z. B.urn:xoev-de:bmk:standard:xbau_2.3) angegeben sein — sonst wird der Reply abgewiesen. - Schlüsselrotation im Case: Wenn der Onlinedienst seinen Schlüssel rotiert, muss dies in einer neuen Submission an den Subscriber kommuniziert werden. Es gilt immer der Schlüssel der neuesten Submission im Case.
Weiter geht's
- Status verfolgen — Submission- und Reply-Lifecycle nachverfolgen
- Callbacks prüfen — Push-Benachrichtigungen statt Polling
- Anträge abholen und prüfen — vollständige Verwaltungs-Anleitung
- Konzept: Schlüssel und Rollover — warum Rückkanalschlüssel pro Vorgang gelten