// Client per le API SignOTP (.NET 6 o superiore, nessuna dipendenza esterna). // Documentazione: https://signotp.com/sviluppatori/ using System.Net.Http.Headers; using System.Net.Http.Json; using System.Security.Cryptography; using System.Text; using System.Text.Json; using System.Text.Json.Serialization; namespace SignOtp; public sealed class SignOtpClient : IDisposable { public static readonly JsonSerializerOptions Json = new(JsonSerializerDefaults.Web) { DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, }; private readonly HttpClient _api; private readonly HttpClient _upload = new(); // l'upload va direttamente su S3, senza x-api-key public SignOtpClient(string apiKey, string baseUrl = "https://api.signotp.com") { _api = new HttpClient { BaseAddress = new Uri(baseUrl) }; _api.DefaultRequestHeaders.Add("x-api-key", apiKey); } /// 1) Crea una pratica (o un nuovo documento su una pratica esistente con ExistingUid). public Task CreateRequestAsync(CreateRequest request, CancellationToken ct = default) => SendAsync(HttpMethod.Post, "/v1/requests", request, ct); /// 2) Carica il PDF sulla URL temporanea restituita da CreateRequestAsync (valida 15 minuti). public async Task UploadPdfAsync(UploadInfo upload, byte[] pdf, CancellationToken ct = default) { using var content = new ByteArrayContent(pdf); content.Headers.ContentType = new MediaTypeHeaderValue("application/pdf"); using var res = await _upload.PutAsync(upload.Url, content, ct); res.EnsureSuccessStatusCode(); } /// 3) Chiude l'inizializzazione: restituisce SignUrl da inviare all'utente. public Task FinalizeAsync(string guid, CancellationToken ct = default) => SendAsync(HttpMethod.Post, $"/v1/requests/{guid}/finalize", null, ct); /// Crea la pratica, carica il PDF e la finalizza in un'unica chiamata. public async Task StartSigningAsync(CreateRequest request, byte[] pdf, CancellationToken ct = default) { var created = await CreateRequestAsync(request, ct); await UploadPdfAsync(created.Upload, pdf, ct); return await FinalizeAsync(created.Guid, ct); } /// Stato della pratica e di tutti i suoi documenti. public Task GetRequestAsync(string guid, CancellationToken ct = default) => SendAsync(HttpMethod.Get, $"/v1/requests/{guid}", null, ct); /// Scarica il PDF firmato (o l'originale) del documento docNumber. public async Task DownloadAsync(string guid, int docNumber, bool signed = true, CancellationToken ct = default) { // l'API risponde 302 verso una URL S3 temporanea: HttpClient segue il redirect using var res = await _api.GetAsync($"/v1/requests/{guid}/documents/{docNumber:00}/{(signed ? "signed" : "original")}", ct); if (!res.IsSuccessStatusCode) throw await SignOtpException.FromAsync(res, ct); return await res.Content.ReadAsByteArrayAsync(ct); } private async Task SendAsync(HttpMethod method, string path, object? body, CancellationToken ct) { using var req = new HttpRequestMessage(method, path); if (body is not null) req.Content = JsonContent.Create(body, options: Json); using var res = await _api.SendAsync(req, ct); if (!res.IsSuccessStatusCode) throw await SignOtpException.FromAsync(res, ct); return (await res.Content.ReadFromJsonAsync(Json, ct))!; } public void Dispose() { _api.Dispose(); _upload.Dispose(); } } /// Verifica delle callback ricevute da SignOTP. public static class SignOtpWebhook { /// corpo della richiesta così come ricevuto (non riserializzato) /// header X-SignOTP-Timestamp /// header X-SignOTP-Signature /// webhook secret della propria applicazione public static bool Verify(string rawBody, string? timestamp, string? signature, string secret, int toleranceSeconds = 300) { if (string.IsNullOrEmpty(timestamp) || string.IsNullOrEmpty(signature)) return false; if (!long.TryParse(timestamp, out var ts)) return false; if (Math.Abs(DateTimeOffset.UtcNow.ToUnixTimeSeconds() - ts) > toleranceSeconds) return false; using var hmac = new HMACSHA256(Encoding.UTF8.GetBytes(secret)); var hash = hmac.ComputeHash(Encoding.UTF8.GetBytes($"{timestamp}.{rawBody}")); var expected = "sha256=" + Convert.ToHexString(hash).ToLowerInvariant(); return CryptographicOperations.FixedTimeEquals(Encoding.UTF8.GetBytes(expected), Encoding.UTF8.GetBytes(signature)); } } public sealed class SignOtpException : Exception { public int StatusCode { get; } public string? Code { get; } private SignOtpException(int status, string? code, string message) : base(message) { StatusCode = status; Code = code; } internal static async Task FromAsync(HttpResponseMessage res, CancellationToken ct) { string? code = null, message = null; try { var body = await res.Content.ReadFromJsonAsync(cancellationToken: ct); var err = body.GetProperty("error"); code = err.GetProperty("code").GetString(); message = err.GetProperty("message").GetString(); } catch { /* corpo non JSON */ } return new SignOtpException((int)res.StatusCode, code, message ?? $"HTTP {(int)res.StatusCode}"); } } // ---- Modelli ---- public record CreateRequest(string? Text = null, string? Email = null, string? CallbackUrl = null, string? ExistingUid = null); public record UploadInfo(string Method, string Url, Dictionary Headers, DateTimeOffset ExpiresAt, long MaxBytes); public record CreateResponse(string Guid, int DocNumber, string FileName, string Status, UploadInfo Upload, string FinalizeUrl); public record FinalizeResponse(string Guid, int DocNumber, string FileName, string Status, int Pages, string Sha256, string SignUrl); public record SignedHashes(string Original, string Signed); public record SignedCallback( string Event, string Guid, int DocNumber, string FileName, DateTimeOffset SignedAt, string Phone, string Ip, SignedHashes Sha256, string SignedPdfUrl, DateTimeOffset SignedPdfUrlExpiresAt, string DownloadUrl);