using System.Net.Mime; using System.Xml; using Autofac; using FitConnect.Encryption; using FitConnect.Interfaces.Subscriber; using FitConnect.Models; using FitConnect.Models.v1.Api; using FitConnect.Services.Models; using FitConnect.Services.Models.v1.Destination; using FitConnect.Services.Models.v1.Submission; using IdentityModel; using Microsoft.AspNetCore.Http; using Microsoft.Extensions.Logging; using Newtonsoft.Json; using Newtonsoft.Json.Linq; using Newtonsoft.Json.Schema; using JsonSchema = NJsonSchema.JsonSchema; using Metadata = FitConnect.Models.Api.Metadata.Metadata; using SecurityEventToken = FitConnect.Models.SecurityEventToken; namespace FitConnect; /// <summary> /// Fluent API for the FitConnect.Subscriber /// </summary> public class Subscriber : FitConnectClient, ISubscriberWithSubmission, ISubscriber { private IContainer? _container; internal Subscriber(IContainer container) : base(FitConnectEnvironment.Testing, container.Resolve<IFitConnectSettings>().SubscriberClientId, container.Resolve<IFitConnectSettings>().SubscriberClientSecret, container, container.Resolve<IFitConnectSettings>().PrivateKeyDecryption, container.Resolve<IFitConnectSettings>().PrivateKeySigning) { Encryption = new FitEncryption( container.Resolve<IFitConnectSettings>().PrivateKeyDecryption, container.Resolve<IFitConnectSettings>().PrivateKeySigning, container.Resolve<ILogger>()); _container = container; } public Subscriber(FitConnectEnvironment environment, string clientId, string clientSecret, string privateKeyDecryption, string privateKeySigning, ILogger? logger = null) : base(environment, clientId, clientSecret, logger, privateKeyDecryption, privateKeySigning) { Encryption = new FitEncryption(privateKeyDecryption, privateKeySigning, logger); } /// <summary> /// Receives the available submissions from the server /// </summary> /// <param name="destinationId"></param> /// <param name="skip"></param> /// <param name="take"></param> /// <returns></returns> public async Task<IEnumerable<SubmissionForPickupDto>> GetAvailableSubmissionsAsync( string? destinationId = null, int skip = 0, int take = 100) { var submissionsResult = await SubmissionService.ListSubmissionsAsync(destinationId, 0, 100); // Creating a dictionary of destinationId to submissionIds from the REST API result return submissionsResult.Submissions ?? new List<SubmissionForPickupDto>(); } /// <summary> /// Receives a specific submission from the server and verifies submission<br /> /// https://docs.fitko.de/fit-connect/docs/receiving/verification/ /// </summary> /// <param name="submissionId"></param> /// <returns></returns> public async Task<ISubscriberWithSubmission> RequestSubmissionAsync(string submissionId) { var submission = (Submission)await SubmissionService.GetSubmissionAsync(submissionId); // SuccessCriteria:2.2, 2.3 var (submitEvent, metadataSignature) = await CheckSecurityEventTokensAsync(submission); // SuccessCriteria:3.1 if (metadataSignature != submission.EncryptedMetadata ?.Split(ProjectSpecification.TokenSeparator).Last()) { var problem = new Problems(Problems.ProblemTypeEnum.IncorrectAuthenticationTag, Problems.TitleAuthenticationTagInvalid, Problems.DetailAuthenticationMetadataInvalid, Problems.ProblemInstanceEnum.Metadata); await RejectSubmissionAsync(submission, problem); throw new SecurityEventException(problem); } string? metadataString; try { var (metaDataString, _, metaHash) = Encryption.Decrypt(submission.EncryptedMetadata!); metadataString = metaDataString; if (metadataString == null) throw new Exception("Unable to decrypt metadata"); } catch (Exception e) { // SuccessCriteria:3.2 var problem = new Problems(Problems.ProblemTypeEnum.EncryptionIssue, Problems.DetailDecryptionIssueMetadata); await RejectSubmissionAsync(submission, problem); throw new SecurityEventException(problem, e); } // SuccessCriteria:3.4 submission.Metadata = JsonConvert.DeserializeObject<Metadata>(metadataString); if (submission.Metadata?.Schema == null) { var problem = new Problems(Problems.ProblemTypeEnum.MissingSchema, Problems.TitleMissingSchema, Problems.DetailMissingSchema, Problems.ProblemInstanceEnum.Metadata); await RejectSubmissionAsync(submission, problem); throw new SecurityEventException(problem); } JsonSchema? schema; try { schema = await JsonSchema.FromUrlAsync(submission.Metadata?.Schema); } catch (Exception e) { // SuccessCriteria:3.5 var problem = new Problems(Problems.ProblemTypeEnum.UnsupportedMetaSchema, Problems.DetailUnsupportedSchema); await RejectSubmissionAsync(submission, problem); throw new SecurityEventException(problem, e); } await VerifyMetadata(submission, metadataString, schema); // SuccessCriteria:3.3 if (submission.Metadata == null) { var problem = new Problems(Problems.ProblemTypeEnum.SyntaxViolation, Problems.DetailSyntaxViolationMetadata); await RejectSubmissionAsync(submission, problem); throw new SecurityEventException(problem); } // SuccessCriteria:3.8 if (submission.Metadata.PublicServiceType.Identifier != submission.ServiceType.Identifier) { var problem = new Problems(Problems.ProblemTypeEnum.ServiceMismatch, Problems.DetailServiceMismatch); await RejectSubmissionAsync(submission, problem); throw new SecurityEventException(problem); } // SuccessCriteria:3.9 var destination = await DestinationService.GetDestinationAsync(submission.DestinationId); if (destination == null) throw new ArgumentException("Destination not found"); var destinationService = destination.Services!.FirstOrDefault(s => s.Identifier == submission.Metadata.PublicServiceType.Identifier); if (destinationService == null) { var problem = new Problems(Problems.ProblemTypeEnum.UnsupportedService, Problems.DetailUnsupportedService); await RejectSubmissionAsync(submission, problem); throw new SecurityEventException(problem); } await CheckReplyChannelAsync(submission, destination); string? dataString = null; if (submission.EncryptedData != null) try { (dataString, _, _) = Encryption.Decrypt(submission.EncryptedData); submission.Data = dataString; if (dataString == null) throw new Exception("DataString can not be null if decryption succeeded"); } catch (Exception e) { // SuccessCriteria: 4.2 var problem = new Problems(Problems.ProblemTypeEnum.EncryptionIssue, Problems.DetailDecryptionIssueMetadata, Problems.DetailDecryptionIssueData, Problems.ProblemInstanceEnum.Data); await RejectSubmissionAsync(submission, problem); throw new SecurityEventException(problem, e); } // SuccessCriteria: 4.3 (Wrong problem?!) await VerifyDataHashAsync(submission, dataString!); // SuccessCriteria:3.10 if (submission.Data == null) { var problem = new Problems(Problems.ProblemTypeEnum.MissingData, Problems.DetailMissingData); await RejectSubmissionAsync(submission, problem); throw new SecurityEventException(problem); } // SuccessCriteria:3.11 if (submission.DataMimeType == MediaTypeNames.Application.Json) { var dataSchema = GetSchemaUrlFromJson(submission.Data); if (dataSchema == null) Logger?.LogWarning( "$schema attribute missing in submission data of submission {SubmissionId}", submission.Id); } if (destinationService.SubmissionSchemas != null && destinationService .SubmissionSchemas .Select(s => s.SchemaUri) .Contains(submission.Metadata.ContentStructure.Data.SubmissionSchema.SchemaUri)) { var problem = new Problems(Problems.ProblemTypeEnum.UnsupportedDataSchema, Problems.DetailUnsupportedDataSchema); await RejectSubmissionAsync(submission, problem); throw new SecurityEventException(problem); } // SuccessCriteria:4.4 if (submission.DataMimeType == MediaTypeNames.Application.Json) if (JsonConvert.DeserializeObject(submission.Data) == null) { var problem = new Problems( Problems.ProblemTypeEnum.SyntaxViolation, Problems.TitleSyntaxViolation, Problems.DetailSyntaxViolationDataJson, Problems.ProblemInstanceEnum.Data); await RejectSubmissionAsync(submission, problem); throw new SecurityEventException(problem); } if (submission.DataMimeType == MediaTypeNames.Application.Xml) try { var doc = new XmlDocument(); doc.LoadXml(submission.Data); } catch (Exception e) { var problem = new Problems( Problems.ProblemTypeEnum.SyntaxViolation, Problems.TitleSyntaxViolation, Problems.DetailSyntaxViolationDataXml, Problems.ProblemInstanceEnum.Data); await RejectSubmissionAsync(submission, problem); throw new SecurityEventException(problem, e); } submission.Attachments = await DownloadAttachmentsAsync(submission); // SuccessCriteria:2.4 await CheckAttachments(submission, submitEvent); Submission = submission; return this; } public async Task RejectSubmissionAsync(ISubmitted submission, params Problems[] problems) { await CompleteSubmission(submission, FinishSubmissionStatus.Rejected, problems); } public Submission? Submission { get; private set; } /// <summary> /// Reading attachments for a submission. /// </summary> /// <returns></returns> /// <summary> /// Reading attachments for a submission. /// </summary> /// <returns></returns> public async Task<IEnumerable<Attachment>> GetAttachmentsAsync() { if (Submission?.Id == null || Submission?.Metadata == null) throw new Exception("No submission available"); var attachments = new List<Attachment>(); foreach (var id in Submission!.AttachmentIds) { var encryptedAttachment = await SubmissionService.GetAttachmentAsync(Submission.Id, id); var (_, content, hash) = Encryption.Decrypt(encryptedAttachment); var attachmentMeta = Submission.Metadata.ContentStructure.Attachments.First(a => a.AttachmentId == id); attachments.Add(new Attachment(id, attachmentMeta, content, encryptedAttachment.Split(ProjectSpecification.TokenSeparator).Last()) { Description = attachmentMeta.Description, Purpose = attachmentMeta.Purpose, MimeType = attachmentMeta.MimeType }); } Submission.Attachments = attachments; return attachments; } public async Task AcceptSubmissionAsync() { await CompleteSubmissionAsync(Submission!, FinishSubmissionStatus.Accepted); } public async Task RejectSubmissionAsync(params Problems[] problems) { await CompleteSubmissionAsync(Submission!, FinishSubmissionStatus.Rejected, problems); } public bool VerifyStatus(AcceptanceStatus acceptanceStatus) { if (Submission == null) throw new NullReferenceException("Submission is null"); var result = true; result &= acceptanceStatus.Metadata == Submission.MetaAuthentication; result &= acceptanceStatus.Data == Submission.DataAuthentication; if (Submission.Attachments != null) foreach (var attachment in Submission.Attachments) result &= acceptanceStatus.Attachments[attachment.Id] == attachment.AttachmentAuthentication; return result; } private string? GetSchemaUrlFromJson(string? jsonString) { return jsonString == null ? null : (JsonConvert.DeserializeObject(jsonString) as JObject)?["$schema"]?.ToString(); } /// <summary> /// Check Hash of attachment /// </summary> /// <param name="submission"></param> /// <returns></returns> /// <exception cref="SecurityEventException"></exception> private async Task<List<Attachment>> DownloadAttachmentsAsync(Submission submission) { var attachments = new List<Attachment>(); foreach (var id in submission!.AttachmentIds) { string encryptedAttachment; try { encryptedAttachment = await SubmissionService.GetAttachmentAsync(submission.Id, id); } catch (Exception e) { var problem = new Problems(Problems.ProblemTypeEnum.MissingAttachments, Problems.TitleAttachmentMissing, string.Format(Problems.DetailAttachmentMissing, id), $"attachment:{id}"); await RejectSubmissionAsync(submission, problem); throw new SecurityEventException(problem, e); } byte[]? content; byte[]? hash; try { (_, content, hash) = Encryption.Decrypt(encryptedAttachment); } catch (Exception e) { var problem = new Problems( Problems.ProblemTypeEnum.EncryptionIssue, Problems.TitleDecryptionIssue, string.Format(Problems.DetailDecryptionIssueAttachment, id), $"attachment:{id}" ); await RejectSubmissionAsync(submission, problem); throw new SecurityEventException(problem, e); } var attachmentMeta = submission.Metadata?.ContentStructure.Attachments.First(a => a.AttachmentId == id); if (attachmentMeta != null) { attachments.Add(new Attachment(id, attachmentMeta, content, encryptedAttachment.Split(ProjectSpecification.TokenSeparator).Last())); // SuccessCriteria: Hash-Check 5.4 if (attachmentMeta?.Hash.Content != FitEncryption.CalculateHash(content).Content) { var problem = new Problems( Problems.ProblemTypeEnum.HashMismatch, Problems.TitleHashMismatch, string.Format(Problems.DetailHashMismatchAttachment, id), $"attachment:{id}"); await RejectSubmissionAsync(submission, problem); throw new SecurityEventException(problem); } } } return attachments; } private async Task CompleteSubmission(FinishSubmissionStatus status) { await CompleteSubmission(Submission!, status); } private async Task CompleteSubmission(ISubmitted submission, FinishSubmissionStatus status, Problems[]? problems = null) { if (submission.SubmissionId == null || submission.CaseId == null || submission.DestinationId == null) throw new ArgumentException("Submission does not contain all required fields"); if (status != FinishSubmissionStatus.Rejected && problems != null) throw new ArgumentException("Problems can only be set for rejected submissions"); string token; switch (status) { case FinishSubmissionStatus.Rejected: token = await Encryption.CreateRejectSecurityEventToken(submission.SubmissionId, submission.CaseId, submission.DestinationId, problems ?? throw new ArgumentException("On reject problems must be provided")); break; case FinishSubmissionStatus.Accepted: token = await Encryption.CreateAcceptSecurityEventToken(submission); break; default: throw new ArgumentOutOfRangeException(nameof(status), status, null); } Logger?.LogDebug("Token to accept submission: {Token}", token); var result = await CasesService.FinishSubmissionAsync(submission.CaseId, token); Logger?.LogInformation("Submission completed {Status}", result); } /// <summary> /// Accept or reject submission /// </summary> private async Task CompleteSubmissionAsync(ISubmitted submission, FinishSubmissionStatus status, Problems[]? problems = null) { if (submission.SubmissionId == null || submission.CaseId == null || submission.DestinationId == null) throw new ArgumentException("Submission does not contain all required fields"); if (status != FinishSubmissionStatus.Rejected && problems != null) throw new ArgumentException("Problems can only be set for rejected submissions"); // TODO Generate AuthenticationTags for Accept token string? token; switch (status) { case FinishSubmissionStatus.Rejected: token = await Encryption.CreateRejectSecurityEventToken(submission.SubmissionId, submission.CaseId, submission.DestinationId, problems ?? throw new ArgumentException("On reject problems must be provided")); break; case FinishSubmissionStatus.Accepted: if (submission is Submission sub) { var authenticationTags = FitEncryption.GenerateAuthenticationTags(sub); token = await Encryption.CreateAcceptSecurityEventToken(submission, authenticationTags); } else { throw new ArgumentException("Only full qualified submission allowed to accept"); } break; default: throw new ArgumentOutOfRangeException(nameof(status), status, null); } Logger?.LogDebug("Token to accept submission: {Token}", token); var result = await CasesService.FinishSubmissionAsync(submission.CaseId, token); Logger?.LogInformation("Submission completed {Status}", result); } public static string VerifyCallback(string callbackSecret, long timestamp, string body) { if (timestamp < DateTime.Now.AddMinutes(ProjectSpecification.MaxCallbackAge * -1) .ToEpochTime()) throw new ArgumentException("Request is too old"); var hmac = ProjectSpecification.CalculateCallbackHmac(callbackSecret, timestamp, body); return Convert.ToHexString(hmac).ToLower(); } public static bool VerifyCallback(string callbackSecret, HttpRequest request) { if (!request.Headers.ContainsKey(ProjectSpecification.CallbackTimestamp)) throw new ArgumentException("Missing callback-timestamp header"); var timeStampString = request.Headers[ProjectSpecification.CallbackTimestamp].ToString(); if (!long.TryParse(timeStampString, out var timestamp)) throw new ArgumentException("Invalid callback-timestamp header"); var authentication = request.Headers[ProjectSpecification.CallbackAuthentication]; using var requestStream = request.Body; var content = new StreamReader(requestStream).ReadToEnd().Trim(); var result = VerifyCallback(callbackSecret, timestamp, content); if (result != authentication) throw new ArgumentException("Verified request does not match authentication"); return true; } private enum FinishSubmissionStatus { Accepted, Rejected } #region Check Submission and Reject if needed private async Task CheckDataSchemaAsync(string dataSchema, Submission submission) { var dataSchemaObject = JsonSchema.FromUrlAsync(dataSchema).Result; var jSchema = JSchema.Parse(dataSchemaObject.ToJson()); var isValidSchemaData = JObject.Parse(submission.Data!).IsValid(jSchema); if (!isValidSchemaData) { var problem = new Problems(Problems.ProblemTypeEnum.SchemaViolation, Problems.DetailsSchemaViolationData); await RejectSubmissionAsync(submission, problem); throw new SecurityEventException(problem); } } private async Task VerifyDataHashAsync(Submission submission, string dataString) { if (submission.Metadata?.ContentStructure.Data.Hash.Content == FitEncryption.CalculateHash(dataString).Content) return; Logger?.LogWarning("Data hash mismatch: {DataHash} != {CalculatedHash}", submission.Metadata?.ContentStructure.Data.Hash.Content, FitEncryption.CalculateHash(dataString)); // SuccessCriteria: 4.3 var problem = new Problems(Problems.ProblemTypeEnum.HashMismatch, Problems.TitleHashMismatch, Problems.DetailHashMismatchData, Problems.ProblemInstanceEnum.Data); await RejectSubmissionAsync(submission, problem); throw new SecurityEventException(problem); } // SuccessCriteria:3 /// <summary> /// Checking metadata /// </summary> /// <param name="submission"></param> /// <param name="metaDataString"></param> /// <param name="schema"></param> /// <exception cref="Exception"></exception> private async Task VerifyMetadata(Submission submission, string metaDataString, JsonSchema schema) { var valid = await JsonHelper.VerifyMetadata(metaDataString, schema); if (!valid) { Logger?.LogWarning("Invalid metadata: {MetaData}", metaDataString); // SuccessCriteria:3.6 var problem = new Problems(Problems.ProblemTypeEnum.UnsupportedMetaSchema, Problems.DetailUnsupportedSchema); await RejectSubmissionAsync(submission, problem); throw new SecurityEventException(problem); } } /// <summary> /// Compares the reply channel of the submission with the allowed channels of the destination /// </summary> /// <param name="submission"></param> /// <param name="destination"></param> /// <exception cref="SecurityEventException"></exception> private async Task CheckReplyChannelAsync(Submission submission, PublicDestinationDto destination) { var rcSubmission = submission.Metadata?.ReplyChannel; var rcDestination = destination.ReplyChannels; if (rcSubmission == null) return; if (rcSubmission.Elster != null && rcDestination?.Elster != null) return; if (rcSubmission.Fink != null && rcDestination?.Fink != null) return; if (rcSubmission.DeMail != null && rcDestination?.DeMail != null) return; if (rcSubmission.EMail != null && rcDestination?.EMail != null) return; var problems = new Problems(Problems.ProblemTypeEnum.UnsupportedReplyChannel, Problems.DetailUnsupportedReplyChannel); await RejectSubmissionAsync(submission, problems); throw new SecurityEventException(problems); } /// <summary> /// Checking:<br /> /// - Exactly one submit element<br /> /// - Authentication Tag of submit event<br /> /// - Authentication Tag of submission data /// </summary> /// <param name="submission"></param> /// <returns></returns> /// <exception cref="SecurityEventException"></exception> private async Task<(SecurityEventToken, string)> CheckSecurityEventTokensAsync( Submission submission) { List<SecurityEventToken> status; try { status = await GetStatusForSubmissionAsync(submission); } catch (Exception? e) { Logger?.LogWarning("Could not get status for submission: {SubmissionId}", submission.Id); await RejectSubmissionAsync(submission, new Problems(Problems.ProblemTypeEnum.InvalidEventLog, Problems.DetailEventLogInconsistent)); throw new SecurityEventException(Problems.TitleEventLogInconsistent, Problems.DetailEventLogInconsistent, e); } if (status.Where(s => s.SubmissionId == submission.Id) .Count(set => set.EventType == EventType.Submit) != 1) { await RejectSubmissionAsync(submission, new Problems(Problems.ProblemTypeEnum.InvalidEventLog, Problems.DetailEventLogNotExactlyOneSubmit)); throw new SecurityEventException(Problems.TitleEventLogInconsistent, Problems.DetailEventLogNotExactlyOneSubmit); } var submitEvent = status.First(set => set.EventType == EventType.Submit); var authenticationTag = (submitEvent.Events?.SubmitSubmissionEvent?.AuthenticationTags); var dataSignature = authenticationTag?.Data; var metadataSignature = authenticationTag?.Metadata ?? ""; if (submission.EncryptedData?.Split(ProjectSpecification.TokenSeparator).Last() != dataSignature) { // SuccessCriteria: 4.1 var problem = new Problems(Problems.ProblemTypeEnum.IncorrectAuthenticationTag, Problems.DetailHashMismatchData); await RejectSubmissionAsync(submission, problem); throw new SecurityEventException(problem); } if (submission.EncryptedMetadata?.Split(ProjectSpecification.TokenSeparator).Last() != metadataSignature) { var problem = new Problems(Problems.ProblemTypeEnum.IncorrectAuthenticationTag, Problems.DetailAuthenticationMetadataInvalid); await RejectSubmissionAsync(submission, problem); throw new SecurityEventException(problem); } return (submitEvent, metadataSignature); } /// <summary> /// Checking Attachments<br /> /// - Verify list of attachments<br /> /// - Authentication tags of attachments /// </summary> /// <exception cref="SecurityEventException"></exception> private async Task CheckAttachments(Submission submission, SecurityEventToken submitEvent) { if (submission?.Attachments != null) { // @formatter:off // SuccessCriteria::3.12 && 2.4 if (submission.Attachments.Count != submission.Metadata?.ContentStructure.Attachments.Count || submission.Attachments.Count != (submitEvent.Events?.SubmitSubmissionEvent?.AuthenticationTags?.Attachments?.Count ?? 0) || !submission.Attachments.TrueForAll(a =>submission.Metadata?.ContentStructure.Attachments?.Any(ma=>ma.AttachmentId == a.Id) ?? false) || !submission.Attachments.TrueForAll(a =>submitEvent.Events?.SubmitSubmissionEvent?.AuthenticationTags?.Attachments?.ContainsKey(a.Id) ?? false) ) { var problem = new Problems(Problems.ProblemTypeEnum.AttachmentMismatch, Problems.DetailAttachmentsMismatch); await RejectSubmissionAsync(submission, problem); throw new SecurityEventException(problem); } // @formatter:on var attachmentAuthenticationTags = submitEvent.Events!.SubmitSubmissionEvent!.AuthenticationTags!.Attachments; // SuccessCriteria:5.2 var problems = new List<Problems>(); foreach (var attachment in submission.Attachments) if (attachmentAuthenticationTags?[attachment.Id] != attachment.AttachmentAuthentication) { var problem = new Problems(Problems.ProblemTypeEnum.IncorrectAuthenticationTag, string.Format(Problems.DetailAuthenticationAttachmentInvalid, attachment.Id)); problems.Add(problem); } if (problems.Any()) { await RejectSubmissionAsync(submission, problems.ToArray()); throw new SecurityEventException(problems.ToArray()); } } } #endregion }